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
}
// --------------------------------------------------------------------------
@@ -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)
}
}
@@ -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)
}
}
+10 -55
View File
@@ -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) {
+8 -80
View File
@@ -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) ---