package codes import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strconv" "time" "github.com/redis/go-redis/v9" libcodes "github.com/wangjia/codes" "github.com/wangjia/codes/redisx" "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 on top of the shared codes lib. type Service struct { store *Store limiter libcodes.RateLimiter // nil → GuardedRedeem 内部 Noop(cmd/codegen 传 rdb=nil 的场景) } // NewService creates a Service. failMax/lockDur mirror the old per-user // redeem lockout (5 次失败锁 1 小时), now backed by codes/redisx. func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service { var limiter libcodes.RateLimiter if rdb != nil { limiter = redisx.NewRateLimiter(rdb, "redeem:fail:", failMax, lockDur) } return &Service{store: store, limiter: limiter} } // grantOutcome is the host-side grant result threaded through Redeem[T]. type grantOutcome struct { subID int64 expiresAt time.Time } // Redeem processes a redemption request through the shared codes library's // GuardedRedeem (rate-limit + code state-machine + idempotency), with a host // grant callback that extends/creates the pangolin subscription and writes // the legacy audit_log row inside the SAME transaction as the code flip. func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) { ref := "user:" + strconv.FormatInt(req.UserID, 10) // 1. 锁检查先于格式检查(行为等价:旧 service.go:118-124 顺序)。 if svc.limiter != nil { allowed, err := svc.limiter.Allowed(ctx, ref) if err != nil { return nil, apierr.ErrInternal } if !allowed { return nil, apierr.ErrLocked } } // 2. Canonicalize;格式错也计一次失败(旧 service.go:127-131)。 canonical, cerr := Canonicalize(req.Code) if cerr != nil { if svc.limiter != nil { _ = svc.limiter.RecordFailure(ctx, ref) } return nil, apierr.ErrInvalidCode } // 3. 宿主事务:码状态翻转 + 订阅授予 + 双审计,一个 tx 原子提交。 tx, err := svc.store.BeginTx(ctx) if err != nil { return nil, apierr.ErrInternal } committed := false defer func() { if !committed { _ = tx.Rollback() } }() res, err := libcodes.GuardedRedeem(ctx, svc.store.Lib(), svc.limiter, tx, Hash(canonical), ref, svc.grantSubscription(req.UserID)) if err != nil { switch { case errors.Is(err, libcodes.ErrLocked): return nil, apierr.ErrLocked case errors.Is(err, libcodes.ErrCodeNotFound): return nil, apierr.ErrCodeNotFound case errors.Is(err, libcodes.ErrCodeRedeemed): return nil, apierr.ErrCodeRedeemed case errors.Is(err, libcodes.ErrCodeVoid): return nil, apierr.ErrCodeVoid default: return nil, apierr.ErrInternal } } dur, derr := res.Code.Entitlement.DecodeDuration() if derr != nil { return nil, apierr.ErrInternal } if res.Idempotent { // 只读路径:回滚(旧 service.go:162-171 同款),ExpiresAt 留空由 /v1/me 兜底。 _ = tx.Rollback() committed = true return &RedeemResult{ Idempotent: true, PlanCode: PlanCode(dur.Plan), DurationDays: dur.Days, }, nil } if err := tx.Commit(); err != nil { return nil, apierr.ErrInternal } committed = true return &RedeemResult{ Idempotent: false, PlanCode: PlanCode(dur.Plan), DurationDays: dur.Days, ExpiresAt: res.Grant.expiresAt, SubscriptionID: res.Grant.subID, }, nil } // grantSubscription returns the GrantFunc executed by the lib INSIDE the same // tx as the code flip. It reproduces the old applySubscription semantics // byte-for-byte (service.go:235-287 pre-migration) and writes the legacy // audit_log row the admin audit page reads (non-fatal, old behavior). func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutcome] { return func(ctx context.Context, tx *sql.Tx, code libcodes.Code) (grantOutcome, error) { dur, err := code.Entitlement.DecodeDuration() if err != nil { return grantOutcome{}, err } planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanCode(dur.Plan)) if err != nil { return grantOutcome{}, err } subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, dur.Days, "code") if err != nil { return grantOutcome{}, err } // 旧式审计(数据源:admin/store.go:188,197 读 audit_log);失败不中断。 meta, _ := json.Marshal(map[string]any{ "plan": dur.Plan, "duration_days": dur.Days, "batch_id": code.BatchID, "sub_id": subID, }) _ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), "redeem", "code_hash:"+code.CodeHash[:16]+"...", string(meta)) return grantOutcome{subID: subID, expiresAt: expiresAt}, nil } } // applySubscription:同 plan 有活跃订阅 → 取最晚到期那条 max(expires,now)+days // 原地延长;否则新建一行 max(now, latestSamePlan)+days,source 记录新建行的 // 创建者("code"=兑换码,"pay"=支付)。算法体与迁移前完全一致,仅签名从 // (*CodeRow, *apierr.Error) 改为 (planID, days, source, error)。 // ExtendSubscription 路径不看 source:延长的是既有行,行上的 source 仍是 // 该行最初创建者,不因后续延长(不论来源)而改写——台账/审计保留完整出处。 func (svc *Service) applySubscription( ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int, source string, ) (int64, time.Time, error) { subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID) if err != nil { return 0, time.Time{}, err } var samePlanSub *SubscriptionRow var latestSamePlan time.Time for i := range subs { if subs[i].PlanID == 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 { if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, durationDays); err != nil { return 0, time.Time{}, err } base := samePlanSub.ExpiresAt if now.After(base) { base = now } return samePlanSub.ID, base.AddDate(0, 0, durationDays), nil } newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, planID, durationDays, latestSamePlan, source) if err != nil { return 0, time.Time{}, err } base := now if latestSamePlan.After(now) { base = latestSamePlan } return newSubID, base.AddDate(0, 0, durationDays), nil } // -------------------------------------------------------------------------- // 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 via the shared library's Mint // (all-or-nothing: batch + code hashes commit in one tx) and returns the // plaintext codes — the only time they ever appear. func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) { // 未知 plan 先拒(行为等价:旧实现先查 GetPlanID)。 if _, err := svc.store.GetPlanID(ctx, req.PlanCode); err != nil { return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err) } ent, err := libcodes.NewDurationEntitlement(string(req.PlanCode), req.DurationDays) if err != nil { return nil, fmt.Errorf("CreateBatch: %w", err) } res, err := libcodes.Mint(ctx, svc.store.Lib(), libcodes.MintRequest{ Channel: string(req.Channel), Entitlement: ent, Count: req.Count, CreatedBy: req.CreatedBy, Note: req.Note, }) if err != nil { return nil, fmt.Errorf("CreateBatch: %w", err) } return &BatchResult{ BatchID: res.BatchID, Codes: res.Codes, PlanCode: req.PlanCode, DurationDays: req.DurationDays, Channel: req.Channel, }, nil }