feat(server): Redeem 换芯 GuardedRedeem——grant 回调同事务复刻订阅叠加+双审计(#codes-lib)
This commit is contained in:
+110
-189
@@ -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
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user