From 0822d22e2c34a0bca14aa243abc65b063b7abb6d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 14:34:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20Redeem=20=E6=8D=A2=E8=8A=AF=20G?= =?UTF-8?q?uardedRedeem=E2=80=94=E2=80=94grant=20=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E5=90=8C=E4=BA=8B=E5=8A=A1=E5=A4=8D=E5=88=BB=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0+=E5=8F=8C=E5=AE=A1=E8=AE=A1(#codes-lib)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/codes/service.go | 299 +++++++------------ server/internal/codes/service_sqlite_test.go | 178 +++++++++++ server/internal/codes/sqlite_helper_test.go | 15 + server/internal/codes/store.go | 65 +--- server/internal/store/sqlite_stores_test.go | 88 +----- 5 files changed, 321 insertions(+), 324 deletions(-) diff --git a/server/internal/codes/service.go b/server/internal/codes/service.go index 0e5b93d..c9cf8f6 100644 --- a/server/internal/codes/service.go +++ b/server/internal/codes/service.go @@ -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 } // -------------------------------------------------------------------------- diff --git a/server/internal/codes/service_sqlite_test.go b/server/internal/codes/service_sqlite_test.go index a783377..c80a037 100644 --- a/server/internal/codes/service_sqlite_test.go +++ b/server/internal/codes/service_sqlite_test.go @@ -2,6 +2,7 @@ package codes_test import ( "context" + "database/sql" "testing" "time" @@ -55,3 +56,180 @@ func TestCreateBatchViaLib(t *testing.T) { 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) + } +} diff --git a/server/internal/codes/sqlite_helper_test.go b/server/internal/codes/sqlite_helper_test.go index eb8595a..0ca8d67 100644 --- a/server/internal/codes/sqlite_helper_test.go +++ b/server/internal/codes/sqlite_helper_test.go @@ -3,7 +3,9 @@ package codes_test import ( "context" "database/sql" + "fmt" "testing" + "time" "github.com/wangjia/pangolin/server/internal/config" "github.com/wangjia/pangolin/server/internal/store" @@ -28,3 +30,16 @@ func openMigratedSQLite(t *testing.T) *sql.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) + } +} diff --git a/server/internal/codes/store.go b/server/internal/codes/store.go index 42ff997..9af5b24 100644 --- a/server/internal/codes/store.go +++ b/server/internal/codes/store.go @@ -42,19 +42,6 @@ const ( 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. type SubscriptionRow struct { ID int64 @@ -137,48 +124,6 @@ func (s *Store) CreateCode(ctx context.Context, codeHash string, planID int64, d 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) // -------------------------------------------------------------------------- @@ -193,6 +138,16 @@ func (s *Store) GetPlanID(ctx context.Context, code PlanCode) (int64, error) { 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, // ordered by expires_at DESC. Called inside the redeem transaction. func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID int64) ([]SubscriptionRow, error) { diff --git a/server/internal/store/sqlite_stores_test.go b/server/internal/store/sqlite_stores_test.go index 10a6c41..d881523 100644 --- a/server/internal/store/sqlite_stores_test.go +++ b/server/internal/store/sqlite_stores_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - "github.com/wangjia/pangolin/server/internal/codes" "github.com/wangjia/pangolin/server/internal/config" dbx "github.com/wangjia/pangolin/server/internal/db" "github.com/wangjia/pangolin/server/internal/nodes" @@ -190,85 +189,14 @@ func TestSQLite_PersistCredentialUpsert(t *testing.T) { } } -func TestSQLite_CodesRedeemFlow(t *testing.T) { - // SKIP (codes-lib-integration Task 1): migration 000020 renames codes/code_batches - // -> legacy_codes/legacy_code_batches to make room for the github.com/wangjia/codes - // shared library's own `codes` table. This test exercises internal/codes.Store's - // low-level methods (CreateBatch/CreateCode/FindCodeByHashForUpdate/MarkRedeemed), - // which still target the old table names and are slated for removal once - // internal/codes/store.go is re-pointed at the library (Task 4) and the redeem - // path moves to codes.GuardedRedeem (Task 5, which deletes this test in favor of - // 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) - } -} +// NOTE: TestSQLite_CodesRedeemFlow (formerly here, low-level +// internal/codes.Store methods CreateBatch/CreateCode/FindCodeByHashForUpdate/ +// MarkRedeemed) was removed in codes-lib-integration Task 5: the redeem path +// now goes through the shared github.com/wangjia/codes library +// (codes.GuardedRedeem), and those low-level methods no longer exist on +// internal/codes.Store. Equivalent coverage — Service-level, against the real +// call path — lives in internal/codes/service_sqlite_test.go +// (TestRedeem*). // --- seed helpers (raw SQL, satisfy foreign keys) ---