package codes import ( "context" "database/sql" "encoding/json" "fmt" "time" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/apierr" ) // RedeemRequest carries the inputs to a single redemption attempt. type RedeemRequest struct { UserID int64 // authenticated user Code string // raw code string from the client (will be canonicalized) } // RedeemResult carries the outcome of a successful redemption. type RedeemResult struct { // Idempotent indicates this user already redeemed this code; the result is // from the original redemption. Idempotent bool PlanCode PlanCode DurationDays int // ExpiresAt is the updated/new subscription expiry (UTC). ExpiresAt time.Time // SubscriptionID is the subscription that was extended or newly created. SubscriptionID int64 } // Service handles activation-code redemption. type Service struct { store *Store rdb *redis.Client // redeemFailMax is the number of consecutive failures before a 1-hour lock. redeemFailMax int // redeemLockDur is how long the lock lasts. redeemLockDur time.Duration } // NewService creates a Service. func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service { if failMax <= 0 { failMax = 5 } if lockDur <= 0 { lockDur = time.Hour } return &Service{ store: store, rdb: rdb, redeemFailMax: failMax, redeemLockDur: lockDur, } } // redisKeyFail returns the Redis key for the per-user failure counter. func redisKeyFail(userID int64) string { return fmt.Sprintf("redeem:fail:%d", userID) } // isLocked returns true if the user has hit the failure cap. // Returns false (not locked) if Redis is not configured. func (svc *Service) isLocked(ctx context.Context, userID int64) (bool, error) { if svc.rdb == nil { return false, nil } val, err := svc.rdb.Get(ctx, redisKeyFail(userID)).Int() if err == redis.Nil { return false, nil } if err != nil { return false, err } return val >= svc.redeemFailMax, nil } // recordFail increments the failure counter, setting a 1-hour TTL on first // increment so the counter resets automatically after the lock window. // No-ops if Redis is not configured. func (svc *Service) recordFail(ctx context.Context, userID int64) error { if svc.rdb == nil { return nil } key := redisKeyFail(userID) pipe := svc.rdb.Pipeline() pipe.Incr(ctx, key) pipe.Expire(ctx, key, svc.redeemLockDur) _, err := pipe.Exec(ctx) return err } // clearFail removes the failure counter after a successful redemption. // No-ops if Redis is not configured. func (svc *Service) clearFail(ctx context.Context, userID int64) { if svc.rdb == nil { return } _ = svc.rdb.Del(ctx, redisKeyFail(userID)).Err() } // Redeem processes a redemption request inside a serialisable transaction. // // Flow: // 1. Check rate-limit lock. // 2. Canonicalize and hash the code. // 3. BEGIN TRANSACTION (Serializable isolation). // 4. SELECT … FOR UPDATE the codes row. // 5. Idempotency: if already redeemed by this user, return cached success. // 6. Fail if redeemed by someone else, or code is void. // 7. MarkRedeemed, extend/create subscription, write audit_log. // 8. COMMIT. // 9. Clear the failure counter on success. func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) { // 1. Check lock. locked, err := svc.isLocked(ctx, req.UserID) if err != nil { return nil, apierr.ErrInternal } if locked { return nil, apierr.ErrLocked } // 2. Canonicalize and hash. canonical, cerr := Canonicalize(req.Code) if cerr != nil { _ = svc.recordFail(ctx, req.UserID) return nil, apierr.ErrInvalidCode } hash := Hash(canonical) // 3. Begin transaction. tx, err := svc.store.BeginTx(ctx) if err != nil { return nil, apierr.ErrInternal } // Roll back on any unhandled path. committed := false defer func() { if !committed { _ = tx.Rollback() } }() // 4. SELECT … FOR UPDATE. cr, err := svc.store.FindCodeByHashForUpdate(ctx, tx, hash) if err != nil { return nil, apierr.ErrInternal } if cr == nil { _ = tx.Rollback() committed = true // prevent double rollback _ = svc.recordFail(ctx, req.UserID) return nil, apierr.ErrCodeNotFound } // 5. Idempotency check. // Same user has already redeemed this code → return a success result without // re-applying any changes. Roll back the (read-only) transaction first. if cr.Status == "redeemed" && cr.RedeemedBy.Valid && cr.RedeemedBy.Int64 == req.UserID { _ = tx.Rollback() committed = true return &RedeemResult{ Idempotent: true, PlanCode: cr.PlanCode, DurationDays: cr.DurationDays, // ExpiresAt is omitted; the caller can query /v1/me if needed. }, nil } // 6. Fail if already taken or voided. switch cr.Status { case "redeemed": _ = tx.Rollback() committed = true _ = svc.recordFail(ctx, req.UserID) return nil, apierr.ErrCodeRedeemed case "void": _ = tx.Rollback() committed = true _ = svc.recordFail(ctx, req.UserID) return nil, apierr.ErrCodeVoid } // 7a. Mark the code as redeemed. if err := svc.store.MarkRedeemed(ctx, tx, cr.ID, req.UserID); err != nil { return nil, apierr.ErrInternal } // 7b. Extend or create subscription. subID, expiresAt, apiErr := svc.applySubscription(ctx, tx, req.UserID, cr) if apiErr != nil { return nil, apiErr } // 7c. Write audit log. meta := auditMeta(req.UserID, cr, subID) if err := svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", req.UserID), "redeem", "code_hash:"+cr.CodeHash[:16]+"...", meta, ); err != nil { // Audit log failure must not abort the business transaction. // Log the error but continue. _ = err } // 8. Commit. if err := tx.Commit(); err != nil { return nil, apierr.ErrInternal } committed = true // 9. Clear failure counter on success. svc.clearFail(ctx, req.UserID) return &RedeemResult{ Idempotent: false, PlanCode: cr.PlanCode, DurationDays: cr.DurationDays, ExpiresAt: expiresAt, SubscriptionID: subID, }, nil } // applySubscription implements the subscription-extension rules: // // - Same plan as code → extend the most-recently-expiring subscription of // that plan: expires_at = max(expires_at, now) + duration_days // - Different plan (or no existing sub for the code's plan) → create a new // subscription row: // expires_at = max(now, latest_expiry_for_code_plan) + duration_days func (svc *Service) applySubscription( ctx context.Context, tx *sql.Tx, userID int64, cr *CodeRow, ) (subID int64, expiresAt time.Time, apiErr *apierr.Error) { subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID) if err != nil { return 0, time.Time{}, apierr.ErrInternal } // Find any existing subscription with the same plan as the code. var samePlanSub *SubscriptionRow var latestSamePlan time.Time for i := range subs { if subs[i].PlanID == cr.PlanID { if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) { samePlanSub = &subs[i] } if subs[i].ExpiresAt.After(latestSamePlan) { latestSamePlan = subs[i].ExpiresAt } } } now := time.Now().UTC() if samePlanSub != nil { // Extend existing subscription: max(expires_at, now) + duration_days. if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, cr.DurationDays); err != nil { return 0, time.Time{}, apierr.ErrInternal } base := samePlanSub.ExpiresAt if now.After(base) { base = now } expiresAt = base.AddDate(0, 0, cr.DurationDays) return samePlanSub.ID, expiresAt, nil } // Create a new subscription. newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, cr.PlanID, cr.DurationDays, latestSamePlan) if err != nil { return 0, time.Time{}, apierr.ErrInternal } base := now if latestSamePlan.After(now) { base = latestSamePlan } expiresAt = base.AddDate(0, 0, cr.DurationDays) return newSubID, expiresAt, nil } // auditMeta serialises a compact JSON string for the audit log meta field. func auditMeta(userID int64, cr *CodeRow, subID int64) string { m := map[string]interface{}{ "plan": string(cr.PlanCode), "duration_days": cr.DurationDays, "batch_id": cr.BatchID, "sub_id": subID, } b, _ := json.Marshal(m) return string(b) } // -------------------------------------------------------------------------- // Batch generation service // -------------------------------------------------------------------------- // BatchRequest holds the parameters for a bulk code-generation job. type BatchRequest struct { PlanCode PlanCode DurationDays int Count int Channel BatchChannel Note string CreatedBy string // e.g. "admin:1" or "cli" } // BatchResult contains the generated plaintext codes (only occurrence ever) // and the batch metadata. type BatchResult struct { BatchID int64 Codes []string // plaintext canonical codes – NOT stored in DB PlanCode PlanCode DurationDays int Channel BatchChannel } // CreateBatch generates Count activation codes, writes the batch + code hashes // to MySQL, and returns the plaintext codes. The plaintext codes are the ONLY // time they ever appear; the caller is responsible for delivering them securely // (e.g., streaming to CSV without logging). // // Duplicate-hash retries (birthday collision, astronomically unlikely) are // handled automatically up to maxRetries per slot. func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) { const maxRetries = 10 // Look up plan ID. planID, err := svc.store.GetPlanID(ctx, req.PlanCode) if err != nil { return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err) } // Insert the batch record. batchID, err := svc.store.CreateBatch(ctx, req.Channel, req.CreatedBy, req.Note) if err != nil { return nil, fmt.Errorf("CreateBatch: %w", err) } plaintexts := make([]string, 0, req.Count) for i := 0; i < req.Count; i++ { var code string var h string ok := false for attempt := 0; attempt < maxRetries; attempt++ { c, err := GenerateCode() if err != nil { return nil, fmt.Errorf("CreateBatch: GenerateCode: %w", err) } h = Hash(c) err = svc.store.CreateCode(ctx, h, planID, req.DurationDays, batchID) if err == ErrDuplicate { continue // collision – generate a fresh code } if err != nil { return nil, fmt.Errorf("CreateBatch: CreateCode: %w", err) } code = c ok = true break } if !ok { return nil, fmt.Errorf("CreateBatch: exceeded %d retry attempts for slot %d", maxRetries, i) } plaintexts = append(plaintexts, code) } return &BatchResult{ BatchID: batchID, Codes: plaintexts, PlanCode: req.PlanCode, DurationDays: req.DurationDays, Channel: req.Channel, }, nil }