feat(server): codes.GrantPaidSubscriptionTx 提炼订阅叠加语义供 pay 复用
CreateSubscription/applySubscription 加 source 参数(兑换路径传 "code" 零行为变化);新导出 GrantPaidSubscriptionTx(ctx, tx, ...) 供 pay 侧在 调用方事务内以 source='pay' 复用同一段叠加语义(同 plan 活跃订阅原地 延长,否则新建行),写 pay_grant 审计。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -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:<out_trade_no>"),审计写失败不影响事务(与兑换一致)。
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return grantOutcome{}, err
|
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 {
|
if err != nil {
|
||||||
return grantOutcome{}, err
|
return grantOutcome{}, err
|
||||||
}
|
}
|
||||||
@@ -177,10 +177,13 @@ func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applySubscription:同 plan 有活跃订阅 → 取最晚到期那条 max(expires,now)+days
|
// applySubscription:同 plan 有活跃订阅 → 取最晚到期那条 max(expires,now)+days
|
||||||
// 原地延长;否则新建一行 max(now, latestSamePlan)+days。算法体与迁移前完全一致,
|
// 原地延长;否则新建一行 max(now, latestSamePlan)+days,source 记录新建行的
|
||||||
// 仅签名从 (*CodeRow, *apierr.Error) 改为 (planID, days, error)。
|
// 创建者("code"=兑换码,"pay"=支付)。算法体与迁移前完全一致,仅签名从
|
||||||
|
// (*CodeRow, *apierr.Error) 改为 (planID, days, source, error)。
|
||||||
|
// ExtendSubscription 路径不看 source:延长的是既有行,行上的 source 仍是
|
||||||
|
// 该行最初创建者,不因后续延长(不论来源)而改写——台账/审计保留完整出处。
|
||||||
func (svc *Service) applySubscription(
|
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) {
|
) (int64, time.Time, error) {
|
||||||
subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
|
subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -209,7 +212,7 @@ func (svc *Service) applySubscription(
|
|||||||
}
|
}
|
||||||
return samePlanSub.ID, base.AddDate(0, 0, durationDays), nil
|
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 {
|
if err != nil {
|
||||||
return 0, time.Time{}, err
|
return 0, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ func (s *Store) CreateSubscription(
|
|||||||
userID, planID int64,
|
userID, planID int64,
|
||||||
durationDays int,
|
durationDays int,
|
||||||
latestSamePlan time.Time,
|
latestSamePlan time.Time,
|
||||||
|
source string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
base := now
|
base := now
|
||||||
@@ -168,8 +169,8 @@ func (s *Store) CreateSubscription(
|
|||||||
|
|
||||||
res, err := tx.ExecContext(ctx,
|
res, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
|
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
|
||||||
VALUES (?, ?, ?, 'code', ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
userID, planID, expiresAt, now)
|
userID, planID, expiresAt, source, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("store.CreateSubscription: %w", err)
|
return 0, fmt.Errorf("store.CreateSubscription: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user