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
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user