From 7370b17bd9726e92f9943471977f68611dffc548 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 20:49:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20codes.GrantPaidSubscriptionTx?= =?UTF-8?q?=20=E6=8F=90=E7=82=BC=E8=AE=A2=E9=98=85=E5=8F=A0=E5=8A=A0?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E4=BE=9B=20pay=20=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateSubscription/applySubscription 加 source 参数(兑换路径传 "code" 零行为变化);新导出 GrantPaidSubscriptionTx(ctx, tx, ...) 供 pay 侧在 调用方事务内以 source='pay' 复用同一段叠加语义(同 plan 活跃订阅原地 延长,否则新建行),写 pay_grant 审计。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- server/internal/codes/paygrant.go | 31 +++++++ server/internal/codes/paygrant_sqlite_test.go | 82 +++++++++++++++++++ server/internal/codes/service.go | 13 +-- server/internal/codes/store.go | 5 +- 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 server/internal/codes/paygrant.go create mode 100644 server/internal/codes/paygrant_sqlite_test.go diff --git a/server/internal/codes/paygrant.go b/server/internal/codes/paygrant.go new file mode 100644 index 0000000..64b3a54 --- /dev/null +++ b/server/internal/codes/paygrant.go @@ -0,0 +1,31 @@ +package codes + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" +) + +// GrantPaidSubscriptionTx 在调用方事务里执行支付驱动的订阅开通/续期。 +// 与兑换码走同一段叠加语义(applySubscription):同 plan 有活跃订阅则原地 +// 延长 max(expires,now)+days,否则新建 source='pay' 的一行。ref 为审计 +// 追踪串(如 "pay:"),审计写失败不影响事务(与兑换一致)。 +func (svc *Service) GrantPaidSubscriptionTx( + ctx context.Context, tx *sql.Tx, userID int64, plan PlanCode, days int, ref string, +) (subID int64, expiresAt time.Time, err error) { + planID, err := svc.store.GetPlanIDTx(ctx, tx, plan) + if err != nil { + return 0, time.Time{}, err + } + subID, expiresAt, err = svc.applySubscription(ctx, tx, userID, planID, days, "pay") + if err != nil { + return 0, time.Time{}, err + } + meta, _ := json.Marshal(map[string]any{ + "plan": string(plan), "duration_days": days, "sub_id": subID, + }) + _ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), "pay_grant", ref, string(meta)) + return subID, expiresAt, nil +} diff --git a/server/internal/codes/paygrant_sqlite_test.go b/server/internal/codes/paygrant_sqlite_test.go new file mode 100644 index 0000000..1c67778 --- /dev/null +++ b/server/internal/codes/paygrant_sqlite_test.go @@ -0,0 +1,82 @@ +package codes_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +// 新用户:GrantPaidSubscriptionTx 新建一行 source='pay',到期 = now+days。 +func TestGrantPaidSubscription_NewRow(t *testing.T) { + db := openMigratedSQLite(t) + seedUser(t, db, 1) + store := codes.NewStore(db) + svc := codes.NewService(store, nil, 5, time.Hour) + ctx := context.Background() + + tx, err := store.BeginTx(ctx) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + subID, expiresAt, err := svc.GrantPaidSubscriptionTx(ctx, tx, 1, codes.PlanPro, 31, "order:test001") + if err != nil { + t.Fatalf("GrantPaidSubscriptionTx: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + if subID == 0 { + t.Fatal("subID = 0") + } + want := time.Now().UTC().AddDate(0, 0, 31) + if d := expiresAt.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("expiresAt = %v, want ≈ %v", expiresAt, want) + } + var source string + if err := db.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, subID).Scan(&source); err != nil { + t.Fatalf("query source: %v", err) + } + if source != "pay" { + t.Errorf("source = %q, want pay", source) + } +} + +// 已有同 plan 活跃订阅:原地延长(行数不变,expires 累加),复用兑换码同一段叠加语义。 +func TestGrantPaidSubscription_StacksOnActive(t *testing.T) { + db := openMigratedSQLite(t) + seedUser(t, db, 1) + store := codes.NewStore(db) + svc := codes.NewService(store, nil, 5, time.Hour) + ctx := context.Background() + + grant := func(days int) time.Time { + tx, err := store.BeginTx(ctx) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + _, exp, err := svc.GrantPaidSubscriptionTx(ctx, tx, 1, codes.PlanPro, days, "order:test002") + if err != nil { + t.Fatalf("grant: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + return exp + } + first := grant(31) + second := grant(92) + + want := first.AddDate(0, 0, 92) + if d := second.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("stacked expiresAt = %v, want ≈ %v", second, want) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("subscription rows = %d, want 1(原地延长)", n) + } +} diff --git a/server/internal/codes/service.go b/server/internal/codes/service.go index c9cf8f6..e4d7316 100644 --- a/server/internal/codes/service.go +++ b/server/internal/codes/service.go @@ -156,7 +156,7 @@ func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutc if err != nil { return grantOutcome{}, err } - subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, dur.Days) + subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, dur.Days, "code") if err != nil { return grantOutcome{}, err } @@ -177,10 +177,13 @@ func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutc } // applySubscription:同 plan 有活跃订阅 → 取最晚到期那条 max(expires,now)+days -// 原地延长;否则新建一行 max(now, latestSamePlan)+days。算法体与迁移前完全一致, -// 仅签名从 (*CodeRow, *apierr.Error) 改为 (planID, days, error)。 +// 原地延长;否则新建一行 max(now, latestSamePlan)+days,source 记录新建行的 +// 创建者("code"=兑换码,"pay"=支付)。算法体与迁移前完全一致,仅签名从 +// (*CodeRow, *apierr.Error) 改为 (planID, days, source, error)。 +// ExtendSubscription 路径不看 source:延长的是既有行,行上的 source 仍是 +// 该行最初创建者,不因后续延长(不论来源)而改写——台账/审计保留完整出处。 func (svc *Service) applySubscription( - ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int, + ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int, source string, ) (int64, time.Time, error) { subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID) if err != nil { @@ -209,7 +212,7 @@ func (svc *Service) applySubscription( } return samePlanSub.ID, base.AddDate(0, 0, durationDays), nil } - newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, planID, durationDays, latestSamePlan) + newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, planID, durationDays, latestSamePlan, source) if err != nil { return 0, time.Time{}, err } diff --git a/server/internal/codes/store.go b/server/internal/codes/store.go index 455634b..f0ec605 100644 --- a/server/internal/codes/store.go +++ b/server/internal/codes/store.go @@ -158,6 +158,7 @@ func (s *Store) CreateSubscription( userID, planID int64, durationDays int, latestSamePlan time.Time, + source string, ) (int64, error) { now := time.Now().UTC() base := now @@ -168,8 +169,8 @@ func (s *Store) CreateSubscription( res, err := tx.ExecContext(ctx, `INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) - VALUES (?, ?, ?, 'code', ?)`, - userID, planID, expiresAt, now) + VALUES (?, ?, ?, ?, ?)`, + userID, planID, expiresAt, source, now) if err != nil { return 0, fmt.Errorf("store.CreateSubscription: %w", err) }