diff --git a/admin-web/index.html b/admin-web/index.html index fce0452..20b7b52 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -1413,6 +1413,7 @@
Level
${e(h.level)}
HP
${e(h.hp)}/${e(h.maxHp)}
Gold
${e(h.gold)}
+
Subscription
${h.subscriptionActive ? "active" : "inactive"}${h.subscriptionExpiresAt ? " · until " + e(h.subscriptionExpiresAt) : ""}
@@ -1422,6 +1423,9 @@ + Подписка: + + diff --git a/backend/cmd/server/server.exe b/backend/cmd/server/server.exe index fb75048..ed92dde 100644 Binary files a/backend/cmd/server/server.exe and b/backend/cmd/server/server.exe differ diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 3959032..4fefc80 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" "runtime" @@ -1197,6 +1198,108 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) { writeHeroJSON(w, http.StatusOK, hero) } +type grantSubscriptionRequest struct { + // Periods is how many subscription durations to add (stacking extends from current expiry). Default 1. + Periods int `json:"periods"` +} + +// GrantHeroSubscription activates or extends subscription like a purchase, without charging RUB (admin grant). +// POST /admin/heroes/{heroId}/grant-subscription +func (h *AdminHandler) GrantHeroSubscription(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + var req grantSubscriptionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid request body: " + err.Error(), + }) + return + } + periods := req.Periods + if periods < 1 { + periods = 1 + } + if periods > 52 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "periods must be between 1 and 52", + }) + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for grant-subscription", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + + now := time.Now() + for i := 0; i < periods; i++ { + hero.ActivateSubscription(now) + } + + hero.EnsureBuffChargesPopulated(now) + for bt := range model.BuffFreeChargesPerType { + state := hero.GetBuffCharges(bt, now) + subMax := hero.MaxBuffCharges(bt) + if state.Remaining < subMax { + state.Remaining = subMax + hero.BuffCharges[string(bt)] = state + } + } + + payment := &model.Payment{ + HeroID: hero.ID, + Type: model.PaymentType("subscription_admin"), + AmountRUB: 0, + Status: model.PaymentCompleted, + CreatedAt: now, + CompletedAt: &now, + } + if err := h.store.CreatePayment(r.Context(), payment); err != nil { + h.logger.Error("admin: grant-subscription payment row", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to record payment", + }) + return + } + + if err := h.store.Save(r.Context(), hero); err != nil { + h.logger.Error("admin: save hero after grant-subscription", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + + h.logger.Info("admin: subscription granted", + "hero_id", heroID, + "periods", periods, + "expires_at", hero.SubscriptionExpiresAt, + ) + hero.EnsureGearMap() + hero.RefreshDerivedCombatStats(now) + h.engine.ApplyAdminHeroSnapshot(hero) + writeHeroJSON(w, http.StatusOK, hero) +} + // ForceHeroDeath sets the hero to dead (HP 0, state dead), ends active combat, clears buffs/debuffs, // and increments death stats when transitioning from alive. // POST /admin/heroes/{heroId}/force-death diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index fb13072..c7412fa 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -82,6 +82,7 @@ func New(deps Deps) *chi.Mux { r.Post("/heroes/{heroId}/set-hp", adminH.SetHeroHP) r.Post("/heroes/{heroId}/add-potions", adminH.AddPotions) r.Post("/heroes/{heroId}/revive", adminH.ReviveHero) + r.Post("/heroes/{heroId}/grant-subscription", adminH.GrantHeroSubscription) r.Post("/heroes/{heroId}/force-death", adminH.ForceHeroDeath) r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges)