feat(server): Redeem 换芯 GuardedRedeem——grant 回调同事务复刻订阅叠加+双审计(#codes-lib)

This commit is contained in:
wangjia
2026-07-10 14:34:30 +08:00
parent e128c96d22
commit 0822d22e2c
5 changed files with 321 additions and 324 deletions
+110 -189
View File
@@ -4,11 +4,14 @@ 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"
)
@@ -31,113 +34,60 @@ type RedeemResult struct {
SubscriptionID int64
}
// Service handles activation-code redemption.
// Service handles activation-code redemption on top of the shared codes lib.
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
store *Store
limiter libcodes.RateLimiter // nil → GuardedRedeem 内部 Noop(cmd/codegen 传 rdb=nil 的场景)
}
// NewService creates a Service.
// 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 {
if failMax <= 0 {
failMax = 5
}
if lockDur <= 0 {
lockDur = time.Hour
}
return &Service{
store: store,
rdb: rdb,
redeemFailMax: failMax,
redeemLockDur: lockDur,
var limiter libcodes.RateLimiter
if rdb != nil {
limiter = redisx.NewRateLimiter(rdb, "redeem:fail:", failMax, lockDur)
}
return &Service{store: store, limiter: limiter}
}
// redisKeyFail returns the Redis key for the per-user failure counter.
func redisKeyFail(userID int64) string {
return fmt.Sprintf("redeem:fail:%d", userID)
// grantOutcome is the host-side grant result threaded through Redeem[T].
type grantOutcome struct {
subID int64
expiresAt time.Time
}
// 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.
// 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) {
// 1. Check lock.
locked, err := svc.isLocked(ctx, req.UserID)
if err != nil {
return nil, apierr.ErrInternal
}
if locked {
return nil, apierr.ErrLocked
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 and hash.
// 2. Canonicalize;格式错也计一次失败(旧 service.go:127-131)。
canonical, cerr := Canonicalize(req.Code)
if cerr != nil {
_ = svc.recordFail(ctx, req.UserID)
if svc.limiter != nil {
_ = svc.limiter.RecordFailure(ctx, ref)
}
return nil, apierr.ErrInvalidCode
}
hash := Hash(canonical)
// 3. Begin transaction.
// 3. 宿主事务:码状态翻转 + 订阅授予 + 双审计,一个 tx 原子提交。
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 {
@@ -145,111 +95,101 @@ func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResul
}
}()
// 4. SELECT … FOR UPDATE.
cr, err := svc.store.FindCodeByHashForUpdate(ctx, tx, hash)
res, err := libcodes.GuardedRedeem(ctx, svc.store.Lib(), svc.limiter, tx,
Hash(canonical), ref, svc.grantSubscription(req.UserID))
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
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
}
}
// 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 {
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: cr.PlanCode,
DurationDays: cr.DurationDays,
// ExpiresAt is omitted; the caller can query /v1/me if needed.
PlanCode: PlanCode(dur.Plan),
DurationDays: dur.Days,
}, 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,
PlanCode: PlanCode(dur.Plan),
DurationDays: dur.Days,
ExpiresAt: res.Grant.expiresAt,
SubscriptionID: res.Grant.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) {
// 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)
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。算法体与迁移前完全一致,
// 仅签名从 (*CodeRow, *apierr.Error) 改为 (planID, days, error)。
func (svc *Service) applySubscription(
ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int,
) (int64, time.Time, error) {
subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
if err != nil {
return 0, time.Time{}, apierr.ErrInternal
return 0, time.Time{}, err
}
// 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 subs[i].PlanID == planID {
if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) {
samePlanSub = &subs[i]
}
@@ -258,45 +198,26 @@ func (svc *Service) applySubscription(
}
}
}
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
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
}
expiresAt = base.AddDate(0, 0, cr.DurationDays)
return samePlanSub.ID, expiresAt, nil
return samePlanSub.ID, base.AddDate(0, 0, durationDays), nil
}
// Create a new subscription.
newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, cr.PlanID, cr.DurationDays, latestSamePlan)
newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, planID, durationDays, latestSamePlan)
if err != nil {
return 0, time.Time{}, apierr.ErrInternal
return 0, time.Time{}, err
}
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)
return newSubID, base.AddDate(0, 0, durationDays), nil
}
// --------------------------------------------------------------------------