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" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strconv"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
libcodes "github.com/wangjia/codes" libcodes "github.com/wangjia/codes"
"github.com/wangjia/codes/redisx"
"github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/apierr"
) )
@@ -31,113 +34,60 @@ type RedeemResult struct {
SubscriptionID int64 SubscriptionID int64
} }
// Service handles activation-code redemption. // Service handles activation-code redemption on top of the shared codes lib.
type Service struct { type Service struct {
store *Store store *Store
rdb *redis.Client limiter libcodes.RateLimiter // nil → GuardedRedeem 内部 Noop(cmd/codegen 传 rdb=nil 的场景)
// 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. // 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 { func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service {
if failMax <= 0 { var limiter libcodes.RateLimiter
failMax = 5 if rdb != nil {
} limiter = redisx.NewRateLimiter(rdb, "redeem:fail:", failMax, lockDur)
if lockDur <= 0 {
lockDur = time.Hour
}
return &Service{
store: store,
rdb: rdb,
redeemFailMax: failMax,
redeemLockDur: lockDur,
} }
return &Service{store: store, limiter: limiter}
} }
// redisKeyFail returns the Redis key for the per-user failure counter. // grantOutcome is the host-side grant result threaded through Redeem[T].
func redisKeyFail(userID int64) string { type grantOutcome struct {
return fmt.Sprintf("redeem:fail:%d", userID) subID int64
expiresAt time.Time
} }
// isLocked returns true if the user has hit the failure cap. // Redeem processes a redemption request through the shared codes library's
// Returns false (not locked) if Redis is not configured. // GuardedRedeem (rate-limit + code state-machine + idempotency), with a host
func (svc *Service) isLocked(ctx context.Context, userID int64) (bool, error) { // grant callback that extends/creates the pangolin subscription and writes
if svc.rdb == nil { // the legacy audit_log row inside the SAME transaction as the code flip.
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) { func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) {
// 1. Check lock. ref := "user:" + strconv.FormatInt(req.UserID, 10)
locked, err := svc.isLocked(ctx, req.UserID)
if err != nil { // 1. 锁检查先于格式检查(行为等价:旧 service.go:118-124 顺序)。
return nil, apierr.ErrInternal if svc.limiter != nil {
} allowed, err := svc.limiter.Allowed(ctx, ref)
if locked { if err != nil {
return nil, apierr.ErrLocked 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) canonical, cerr := Canonicalize(req.Code)
if cerr != nil { if cerr != nil {
_ = svc.recordFail(ctx, req.UserID) if svc.limiter != nil {
_ = svc.limiter.RecordFailure(ctx, ref)
}
return nil, apierr.ErrInvalidCode return nil, apierr.ErrInvalidCode
} }
hash := Hash(canonical)
// 3. Begin transaction. // 3. 宿主事务:码状态翻转 + 订阅授予 + 双审计,一个 tx 原子提交。
tx, err := svc.store.BeginTx(ctx) tx, err := svc.store.BeginTx(ctx)
if err != nil { if err != nil {
return nil, apierr.ErrInternal return nil, apierr.ErrInternal
} }
// Roll back on any unhandled path.
committed := false committed := false
defer func() { defer func() {
if !committed { if !committed {
@@ -145,111 +95,101 @@ func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResul
} }
}() }()
// 4. SELECT … FOR UPDATE. res, err := libcodes.GuardedRedeem(ctx, svc.store.Lib(), svc.limiter, tx,
cr, err := svc.store.FindCodeByHashForUpdate(ctx, tx, hash) Hash(canonical), ref, svc.grantSubscription(req.UserID))
if err != nil { if err != nil {
return nil, apierr.ErrInternal switch {
} case errors.Is(err, libcodes.ErrLocked):
if cr == nil { return nil, apierr.ErrLocked
_ = tx.Rollback() case errors.Is(err, libcodes.ErrCodeNotFound):
committed = true // prevent double rollback return nil, apierr.ErrCodeNotFound
_ = svc.recordFail(ctx, req.UserID) case errors.Is(err, libcodes.ErrCodeRedeemed):
return nil, apierr.ErrCodeNotFound return nil, apierr.ErrCodeRedeemed
case errors.Is(err, libcodes.ErrCodeVoid):
return nil, apierr.ErrCodeVoid
default:
return nil, apierr.ErrInternal
}
} }
// 5. Idempotency check. dur, derr := res.Code.Entitlement.DecodeDuration()
// Same user has already redeemed this code → return a success result without if derr != nil {
// re-applying any changes. Roll back the (read-only) transaction first. return nil, apierr.ErrInternal
if cr.Status == "redeemed" && cr.RedeemedBy.Valid && cr.RedeemedBy.Int64 == req.UserID { }
if res.Idempotent {
// 只读路径:回滚(旧 service.go:162-171 同款),ExpiresAt 留空由 /v1/me 兜底。
_ = tx.Rollback() _ = tx.Rollback()
committed = true committed = true
return &RedeemResult{ return &RedeemResult{
Idempotent: true, Idempotent: true,
PlanCode: cr.PlanCode, PlanCode: PlanCode(dur.Plan),
DurationDays: cr.DurationDays, DurationDays: dur.Days,
// ExpiresAt is omitted; the caller can query /v1/me if needed.
}, nil }, 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 { if err := tx.Commit(); err != nil {
return nil, apierr.ErrInternal return nil, apierr.ErrInternal
} }
committed = true committed = true
// 9. Clear failure counter on success.
svc.clearFail(ctx, req.UserID)
return &RedeemResult{ return &RedeemResult{
Idempotent: false, Idempotent: false,
PlanCode: cr.PlanCode, PlanCode: PlanCode(dur.Plan),
DurationDays: cr.DurationDays, DurationDays: dur.Days,
ExpiresAt: expiresAt, ExpiresAt: res.Grant.expiresAt,
SubscriptionID: subID, SubscriptionID: res.Grant.subID,
}, nil }, nil
} }
// applySubscription implements the subscription-extension rules: // grantSubscription returns the GrantFunc executed by the lib INSIDE the same
// // tx as the code flip. It reproduces the old applySubscription semantics
// - Same plan as code → extend the most-recently-expiring subscription of // byte-for-byte (service.go:235-287 pre-migration) and writes the legacy
// that plan: expires_at = max(expires_at, now) + duration_days // audit_log row the admin audit page reads (non-fatal, old behavior).
// - Different plan (or no existing sub for the code's plan) → create a new func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutcome] {
// subscription row: return func(ctx context.Context, tx *sql.Tx, code libcodes.Code) (grantOutcome, error) {
// expires_at = max(now, latest_expiry_for_code_plan) + duration_days dur, err := code.Entitlement.DecodeDuration()
func (svc *Service) applySubscription( if err != nil {
ctx context.Context, return grantOutcome{}, err
tx *sql.Tx, }
userID int64, planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanCode(dur.Plan))
cr *CodeRow, if err != nil {
) (subID int64, expiresAt time.Time, apiErr *apierr.Error) { 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) subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
if err != nil { 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 samePlanSub *SubscriptionRow
var latestSamePlan time.Time var latestSamePlan time.Time
for i := range subs { for i := range subs {
if subs[i].PlanID == cr.PlanID { if subs[i].PlanID == planID {
if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) { if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) {
samePlanSub = &subs[i] samePlanSub = &subs[i]
} }
@@ -258,45 +198,26 @@ func (svc *Service) applySubscription(
} }
} }
} }
now := time.Now().UTC() now := time.Now().UTC()
if samePlanSub != nil { if samePlanSub != nil {
// Extend existing subscription: max(expires_at, now) + duration_days. if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, durationDays); err != nil {
if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, cr.DurationDays); err != nil { return 0, time.Time{}, err
return 0, time.Time{}, apierr.ErrInternal
} }
base := samePlanSub.ExpiresAt base := samePlanSub.ExpiresAt
if now.After(base) { if now.After(base) {
base = now base = now
} }
expiresAt = base.AddDate(0, 0, cr.DurationDays) return samePlanSub.ID, base.AddDate(0, 0, durationDays), nil
return samePlanSub.ID, expiresAt, nil
} }
newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, planID, durationDays, latestSamePlan)
// Create a new subscription.
newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, cr.PlanID, cr.DurationDays, latestSamePlan)
if err != nil { if err != nil {
return 0, time.Time{}, apierr.ErrInternal return 0, time.Time{}, err
} }
base := now base := now
if latestSamePlan.After(now) { if latestSamePlan.After(now) {
base = latestSamePlan base = latestSamePlan
} }
expiresAt = base.AddDate(0, 0, cr.DurationDays) return newSubID, base.AddDate(0, 0, durationDays), nil
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)
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@@ -2,6 +2,7 @@ package codes_test
import ( import (
"context" "context"
"database/sql"
"testing" "testing"
"time" "time"
@@ -55,3 +56,180 @@ func TestCreateBatchViaLib(t *testing.T) {
t.Fatal("unknown plan should error") t.Fatal("unknown plan should error")
} }
} }
// --------------------------------------------------------------------------
// Redeem — via GuardedRedeem, grant callback in the same tx as the flip.
// --------------------------------------------------------------------------
func newRedeemFixture(t *testing.T) (*sql.DB, *codes.Service, string) {
t.Helper()
db := openMigratedSQLite(t)
seedUser(t, db, 1)
seedUser(t, db, 2)
store := codes.NewStore(db)
svc := codes.NewService(store, nil, 5, time.Hour)
res, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
PlanCode: codes.PlanPro, DurationDays: 30, Count: 1,
Channel: codes.ChannelManual, CreatedBy: "t",
})
if err != nil {
t.Fatalf("mint: %v", err)
}
return db, svc, res.Codes[0]
}
func TestRedeemCreatesSubscription(t *testing.T) {
ctx := context.Background()
db, svc, plain := newRedeemFixture(t)
r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
if r.Idempotent || r.PlanCode != codes.PlanPro || r.DurationDays != 30 || r.SubscriptionID == 0 {
t.Fatalf("result = %+v", r)
}
wantMin := time.Now().UTC().AddDate(0, 0, 29)
if r.ExpiresAt.Before(wantMin) {
t.Errorf("expires %v, want ≥ ~30d out", r.ExpiresAt)
}
// 订阅真落库(source='code'),码翻转 + redeemed_by 是 "user:1"。
var src string
var exp time.Time
if err := db.QueryRow(`SELECT source, expires_at FROM subscriptions WHERE id=?`, r.SubscriptionID).Scan(&src, &exp); err != nil || src != "code" {
t.Fatalf("sub: src=%q err=%v", src, err)
}
var status, rby string
if err := db.QueryRow(`SELECT status, redeemed_by FROM codes WHERE code_hash=?`, codes.Hash(mustCanonical(t, plain))).Scan(&status, &rby); err != nil {
t.Fatalf("code: %v", err)
}
if status != "redeemed" || rby != "user:1" {
t.Errorf("status=%q redeemed_by=%q", status, rby)
}
// 旧式审计行仍写 audit_log(admin 审计页数据源)。
var audits int
if err := db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='redeem' AND actor='user:1'`).Scan(&audits); err != nil || audits != 1 {
t.Errorf("audit_log redeem rows = %d (err=%v), want 1", audits, err)
}
}
func mustCanonical(t *testing.T, code string) string {
t.Helper()
c, err := codes.Canonicalize(code)
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
return c
}
func TestRedeemIdempotentSameUser(t *testing.T) {
ctx := context.Background()
_, svc, plain := newRedeemFixture(t)
if _, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain}); apiErr != nil {
t.Fatalf("first: %v", apiErr)
}
r2, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
if apiErr != nil {
t.Fatalf("second: %v", apiErr)
}
if !r2.Idempotent || r2.PlanCode != codes.PlanPro || r2.DurationDays != 30 {
t.Fatalf("r2 = %+v", r2)
}
if !r2.ExpiresAt.IsZero() {
t.Errorf("idempotent replay must omit ExpiresAt (old contract), got %v", r2.ExpiresAt)
}
}
func TestRedeemOtherUserRejected(t *testing.T) {
ctx := context.Background()
_, svc, plain := newRedeemFixture(t)
if _, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain}); apiErr != nil {
t.Fatalf("first: %v", apiErr)
}
_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 2, Code: plain})
if apiErr == nil || apiErr.Code != "CODE_REDEEMED" {
t.Fatalf("apiErr = %+v, want CODE_REDEEMED", apiErr)
}
}
func TestRedeemSamePlanExtends(t *testing.T) {
ctx := context.Background()
db, svc, plain := newRedeemFixture(t)
// 预置同 plan 活跃订阅,到期在未来 10 天 → 兑换后应为 +10+30 天(max(expires,now)+days)。
var proID int64
if err := db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID); err != nil {
t.Fatalf("plan: %v", err)
}
base := time.Now().UTC().AddDate(0, 0, 10).Truncate(time.Second)
var subID int64
res, err := db.Exec(`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) VALUES (1, ?, ?, 'code', ?)`,
proID, base, time.Now().UTC())
if err != nil {
t.Fatalf("seed sub: %v", err)
}
subID, _ = res.LastInsertId()
r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
if r.SubscriptionID != subID {
t.Fatalf("extended sub id = %d, want %d (extend, not create)", r.SubscriptionID, subID)
}
want := base.AddDate(0, 0, 30)
if !r.ExpiresAt.Equal(want) {
t.Errorf("expires = %v, want %v", r.ExpiresAt, want)
}
var cnt int
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&cnt); err != nil || cnt != 1 {
t.Errorf("subs = %d, want 1 (extended in place)", cnt)
}
}
func TestRedeemCrossPlanCreatesNew(t *testing.T) {
ctx := context.Background()
db, svc, plain := newRedeemFixture(t) // 码是 pro
var teamID int64
if err := db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID); err != nil {
t.Fatalf("plan: %v", err)
}
if _, err := db.Exec(`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) VALUES (1, ?, ?, 'code', ?)`,
teamID, time.Now().UTC().AddDate(0, 0, 90), time.Now().UTC()); err != nil {
t.Fatalf("seed team sub: %v", err)
}
r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
var cnt int
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&cnt); err != nil || cnt != 2 {
t.Fatalf("subs = %d, want 2 (new pro sub beside team)", cnt)
}
wantMin := time.Now().UTC().AddDate(0, 0, 29)
if r.ExpiresAt.Before(wantMin) {
t.Errorf("new pro sub expires %v, want ~30d (NOT stacked on team's 90d)", r.ExpiresAt)
}
}
func TestRedeemInvalidFormat(t *testing.T) {
ctx := context.Background()
_, svc, _ := newRedeemFixture(t)
_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: "not-a-code"})
if apiErr == nil || apiErr.Code != "INVALID_CODE" {
t.Fatalf("apiErr = %+v, want INVALID_CODE", apiErr)
}
}
func TestRedeemNotFound(t *testing.T) {
ctx := context.Background()
_, svc, _ := newRedeemFixture(t)
// 结构合法但不存在的码:生成一个不入库的。
plain, err := codes.GenerateCode()
if err != nil {
t.Fatal(err)
}
_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
if apiErr == nil || apiErr.Code != "CODE_NOT_FOUND" {
t.Fatalf("apiErr = %+v, want CODE_NOT_FOUND", apiErr)
}
}
@@ -3,7 +3,9 @@ package codes_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"testing" "testing"
"time"
"github.com/wangjia/pangolin/server/internal/config" "github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/store" "github.com/wangjia/pangolin/server/internal/store"
@@ -28,3 +30,16 @@ func openMigratedSQLite(t *testing.T) *sql.DB {
} }
return db return db
} }
// seedUser inserts a minimal `users` row so redeem tests can satisfy the
// subscriptions/audit_log foreign keys.
func seedUser(t *testing.T, db *sql.DB, id int64) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at)
VALUES (?, ?, ?, 'x', ?, 'active', ?)`,
id, fmt.Sprintf("u-%d", id), fmt.Sprintf("u%d@example.com", id), fmt.Sprintf("dp-%d", id),
time.Now().UTC()); err != nil {
t.Fatalf("seed user: %v", err)
}
}
+10 -55
View File
@@ -42,19 +42,6 @@ const (
ChannelManual BatchChannel = "manual" ChannelManual BatchChannel = "manual"
) )
// CodeRow mirrors the `codes` DB row (hash only; no plaintext).
type CodeRow struct {
ID int64
CodeHash string // SHA-256 hex of canonical plaintext
PlanID int64
PlanCode PlanCode
DurationDays int
BatchID int64
Status string // unused | redeemed | void
RedeemedBy sql.NullInt64
RedeemedAt sql.NullTime
}
// SubscriptionRow mirrors the `subscriptions` DB row. // SubscriptionRow mirrors the `subscriptions` DB row.
type SubscriptionRow struct { type SubscriptionRow struct {
ID int64 ID int64
@@ -137,48 +124,6 @@ func (s *Store) CreateCode(ctx context.Context, codeHash string, planID int64, d
return nil return nil
} }
// --------------------------------------------------------------------------
// Redemption (used by Service.Redeem inside a transaction)
// --------------------------------------------------------------------------
// FindCodeByHashForUpdate looks up a codes row by its SHA-256 hash using
// SELECT … FOR UPDATE so that the row is locked for the duration of the
// surrounding transaction. It also fetches the plan code from the plans
// table in the same query.
func (s *Store) FindCodeByHashForUpdate(ctx context.Context, tx *sql.Tx, hash string) (*CodeRow, error) {
row := tx.QueryRowContext(ctx,
`SELECT c.id, c.code_hash, c.plan_id, p.code, c.duration_days,
c.batch_id, c.status, c.redeemed_by, c.redeemed_at
FROM codes c
JOIN plans p ON p.id = c.plan_id
WHERE c.code_hash = ?
`+s.dialect.LockForUpdate(),
hash)
var cr CodeRow
if err := row.Scan(
&cr.ID, &cr.CodeHash, &cr.PlanID, &cr.PlanCode, &cr.DurationDays,
&cr.BatchID, &cr.Status, &cr.RedeemedBy, &cr.RedeemedAt,
); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("store.FindCodeByHashForUpdate: %w", err)
}
return &cr, nil
}
// MarkRedeemed updates a codes row to status='redeemed' within tx.
func (s *Store) MarkRedeemed(ctx context.Context, tx *sql.Tx, codeID, userID int64) error {
_, err := tx.ExecContext(ctx,
`UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=?
WHERE id=? AND status='unused'`,
userID, time.Now().UTC(), codeID)
if err != nil {
return fmt.Errorf("store.MarkRedeemed: %w", err)
}
return nil
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Subscription helpers (used inside redeem transaction) // Subscription helpers (used inside redeem transaction)
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@@ -193,6 +138,16 @@ func (s *Store) GetPlanID(ctx context.Context, code PlanCode) (int64, error) {
return id, nil return id, nil
} }
// GetPlanIDTx is GetPlanID inside a transaction (grant callback runs inside
// the redeem tx and must not touch the pool).
func (s *Store) GetPlanIDTx(ctx context.Context, tx *sql.Tx, code PlanCode) (int64, error) {
var id int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM plans WHERE code=?`, string(code)).Scan(&id); err != nil {
return 0, fmt.Errorf("store.GetPlanIDTx(%s): %w", code, err)
}
return id, nil
}
// GetActiveSubscriptions returns all non-expired subscriptions for userID, // GetActiveSubscriptions returns all non-expired subscriptions for userID,
// ordered by expires_at DESC. Called inside the redeem transaction. // ordered by expires_at DESC. Called inside the redeem transaction.
func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID int64) ([]SubscriptionRow, error) { func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID int64) ([]SubscriptionRow, error) {
+8 -80
View File
@@ -6,7 +6,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/wangjia/pangolin/server/internal/codes"
"github.com/wangjia/pangolin/server/internal/config" "github.com/wangjia/pangolin/server/internal/config"
dbx "github.com/wangjia/pangolin/server/internal/db" dbx "github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/nodes" "github.com/wangjia/pangolin/server/internal/nodes"
@@ -190,85 +189,14 @@ func TestSQLite_PersistCredentialUpsert(t *testing.T) {
} }
} }
func TestSQLite_CodesRedeemFlow(t *testing.T) { // NOTE: TestSQLite_CodesRedeemFlow (formerly here, low-level
// SKIP (codes-lib-integration Task 1): migration 000020 renames codes/code_batches // internal/codes.Store methods CreateBatch/CreateCode/FindCodeByHashForUpdate/
// -> legacy_codes/legacy_code_batches to make room for the github.com/wangjia/codes // MarkRedeemed) was removed in codes-lib-integration Task 5: the redeem path
// shared library's own `codes` table. This test exercises internal/codes.Store's // now goes through the shared github.com/wangjia/codes library
// low-level methods (CreateBatch/CreateCode/FindCodeByHashForUpdate/MarkRedeemed), // (codes.GuardedRedeem), and those low-level methods no longer exist on
// which still target the old table names and are slated for removal once // internal/codes.Store. Equivalent coverage — Service-level, against the real
// internal/codes/store.go is re-pointed at the library (Task 4) and the redeem // call path — lives in internal/codes/service_sqlite_test.go
// path moves to codes.GuardedRedeem (Task 5, which deletes this test in favor of // (TestRedeem*).
// an equivalent Service-level sqlite case — see plan §Task 5).
t.Skip("superseded by codes-lib-integration Task 5 (internal/codes Store/Redeem move to shared lib); low-level methods here still target renamed legacy_* tables")
ctx := context.Background()
db := openSQLite(t)
seedUser(t, db, 1)
cs := codes.NewStore(db)
// Seed a batch + an 'unused' code for plan 'pro' (seeded id resolved via store).
planID, err := cs.GetPlanID(ctx, codes.PlanPro)
if err != nil {
t.Fatalf("plan id: %v", err)
}
batchID, err := cs.CreateBatch(ctx, codes.ChannelManual, "tester", "")
if err != nil {
t.Fatalf("create batch: %v", err)
}
const hash = "abc123hash"
if err := cs.CreateCode(ctx, hash, planID, 30, batchID); err != nil {
t.Fatalf("create code: %v", err)
}
// Redeem inside a tx: lock the code (FOR UPDATE on mysql / BEGIN IMMEDIATE on
// sqlite), mark redeemed, create a subscription with Go-computed expiry.
tx, err := cs.BeginTx(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
cr, err := cs.FindCodeByHashForUpdate(ctx, tx, hash)
if err != nil || cr == nil {
_ = tx.Rollback()
t.Fatalf("find for update: cr=%v err=%v", cr, err)
}
if cr.Status != "unused" {
_ = tx.Rollback()
t.Fatalf("code status = %q, want unused", cr.Status)
}
if err := cs.MarkRedeemed(ctx, tx, cr.ID, 1); err != nil {
_ = tx.Rollback()
t.Fatalf("mark redeemed: %v", err)
}
subID, err := cs.CreateSubscription(ctx, tx, 1, planID, 30, time.Time{})
if err != nil {
_ = tx.Rollback()
t.Fatalf("create sub: %v", err)
}
// Extend by 10 more days (Go-side max(expires,now)+days).
if err := cs.ExtendSubscription(ctx, tx, subID, 10); err != nil {
_ = tx.Rollback()
t.Fatalf("extend: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
// Verify: code redeemed; subscription expires ~40 days out.
var status string
if err := db.QueryRow(`SELECT status FROM codes WHERE id=?`, cr.ID).Scan(&status); err != nil {
t.Fatalf("read code: %v", err)
}
if status != "redeemed" {
t.Errorf("code status = %q, want redeemed", status)
}
var expires time.Time
if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE id=?`, subID).Scan(&expires); err != nil {
t.Fatalf("read sub: %v", err)
}
wantMin := time.Now().UTC().AddDate(0, 0, 39)
if expires.Before(wantMin) {
t.Errorf("subscription expires_at = %v, want ≥ ~40 days out (%v)", expires, wantMin)
}
}
// --- seed helpers (raw SQL, satisfy foreign keys) --- // --- seed helpers (raw SQL, satisfy foreign keys) ---