feat(billing): 订阅制后端 —— 手动购买 + 周期发放积分 + 到期失效
规格(按需求):用户扫码买一个订阅周期,有效期内每 N 天发一次积分,到期即失效, 不自动续费。N 与每次发放额度都在套餐里配,后台可改。 为什么不做自动续费:微信 Native 扫码支付没有代扣能力,真自动续费要走「委托代扣」 ——另一套产品与资质。与其假装有,不如把"到期即失效"这个语义做扎实。 两个决定,都写进了代码注释: - **发放语义是累加而非重置**。每次刷新写一条 grant 分录、余额累加。重置型 (月度配额清零)会让「余额 = SUM(ledger)」这条对账不变量变复杂,且有误清 用户自费积分的风险。 - **订阅开通放在 store.MarkOrderPaid 内**,而不是各调用方。回调与掉单补偿两条 路都经过它,放这一处才没人能漏掉;按 orderID 幂等,重复调用无害。 复用而非另造:订阅单与积分包单走同一条支付链路(下单/回调/查单/掉单补偿), 只是 kind=sub 且 credits_micro=0——积分不在付款时给,由订阅按周期发。 定时器每 10 分钟扫一轮,语义与幂等都在 store.TickSubscription 里,与手动触发 共用,不会两处漂移。停机期间欠下的发放会一次性补齐。 8 组测试。其中一条当场抓到真 bug:开通时原本无条件发一笔,同一订单重复开通 (回调重推/查单赛跑)会因序号自增绕过幂等索引,白送积分。改为开通也走与定时器 同一套排期判断——排期天然幂等。教训:幂等键要锚在业务时间轴上,不能靠自增序号。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 订阅是涉及钱的路径:发多了是白送,发少了是欠付费用户的。这组测试钉死三件事——
|
||||
// 幂等(重跑不重复发)、补发(漏跑要补齐)、到期边界(过期后一分不发)。
|
||||
|
||||
func seedPlan(t *testing.T, p *Postgres, durationDays, intervalDays int, credits int64) *SubscriptionPlan {
|
||||
t.Helper()
|
||||
pl := &SubscriptionPlan{
|
||||
Name: "测试套餐", PriceFen: 9900, DurationDays: durationDays,
|
||||
RefillCreditsMicro: credits, RefillIntervalDays: intervalDays, Active: true,
|
||||
}
|
||||
if err := p.SaveSubPlan(context.Background(), pl); err != nil {
|
||||
t.Fatalf("建套餐失败: %v", err)
|
||||
}
|
||||
return pl
|
||||
}
|
||||
|
||||
func balance(t *testing.T, p *Postgres, tenantID string) int64 {
|
||||
t.Helper()
|
||||
return p.TenantBalance(WithoutTenant(context.Background()), tenantID)
|
||||
}
|
||||
|
||||
func TestSubscription_ActivateGrantsFirstRefill(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||
|
||||
sub, err := p.ActivateSubscription(context.Background(), "t1", pl.ID, "order-1")
|
||||
if err != nil {
|
||||
t.Fatalf("开通失败: %v", err)
|
||||
}
|
||||
if sub.Status != SubActive {
|
||||
t.Fatalf("应为 active,得 %q", sub.Status)
|
||||
}
|
||||
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||
t.Fatalf("开通即应发第一笔,余额应 100e6,得 %d", got)
|
||||
}
|
||||
assertBalanceInvariant(t, p, "t1")
|
||||
}
|
||||
|
||||
// 同一订单重复开通(回调重推 / 查单与回调赛跑)不能重复延期、不能重复发放。
|
||||
func TestSubscription_ActivateIsIdempotentPerOrder(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||
ctx := context.Background()
|
||||
|
||||
s1, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s1.ExpiresAt.Equal(s2.ExpiresAt) {
|
||||
t.Fatalf("同一订单重复开通不该延期:%v → %v", s1.ExpiresAt, s2.ExpiresAt)
|
||||
}
|
||||
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||
t.Fatalf("重复开通不该重复发放,余额应仍为 100e6,得 %d", got)
|
||||
}
|
||||
assertBalanceInvariant(t, p, "t1")
|
||||
}
|
||||
|
||||
// 续订(不同订单)应在原到期时间上顺延,而不是新建第二条 active。
|
||||
func TestSubscription_RenewExtendsInsteadOfDuplicating(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||
ctx := context.Background()
|
||||
|
||||
s1, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||
s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s1.ID != s2.ID {
|
||||
t.Fatalf("续订应复用同一条订阅,得两条:%s / %s", s1.ID, s2.ID)
|
||||
}
|
||||
want := s1.ExpiresAt.AddDate(0, 0, 30)
|
||||
if !s2.ExpiresAt.Equal(want) {
|
||||
t.Fatalf("续订应顺延 30 天:want %v got %v", want, s2.ExpiresAt)
|
||||
}
|
||||
var n int64
|
||||
p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
|
||||
Where("tenant_id = ? AND status = ?", "t1", SubActive).Count(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("同租户不应出现多条 active 订阅,得 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时器漏跑(进程停机数周)后要把欠下的次数一次补齐,而不是只补最近一次。
|
||||
func TestSubscription_TickBackfillsMissedRefills(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
pl := seedPlan(t, p, 30, 7, 100_000_000) // 30 天订阅,每 7 天发一次
|
||||
ctx := context.Background()
|
||||
|
||||
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1") // 已发第 1 笔
|
||||
// 快进 22 天:第 7/14/21 天各应发一次,共补 3 笔
|
||||
now := sub.StartedAt.AddDate(0, 0, 22)
|
||||
granted, expired, err := p.TickSubscription(ctx, sub, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if expired {
|
||||
t.Fatal("22 天时不该过期(周期 30 天)")
|
||||
}
|
||||
if granted != 3 {
|
||||
t.Fatalf("应补发 3 笔(第 7/14/21 天),得 %d", granted)
|
||||
}
|
||||
if got := balance(t, p, "t1"); got != 400_000_000 {
|
||||
t.Fatalf("首笔 + 补 3 笔 = 400e6,得 %d", got)
|
||||
}
|
||||
assertBalanceInvariant(t, p, "t1")
|
||||
}
|
||||
|
||||
// 同一时刻重复 tick(多实例并发 / 定时器重叠)不能重复发放。
|
||||
func TestSubscription_TickIsIdempotent(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||
ctx := context.Background()
|
||||
|
||||
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||
now := sub.StartedAt.AddDate(0, 0, 8)
|
||||
|
||||
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := balance(t, p, "t1")
|
||||
// 再 tick 两次,余额不能变
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if after := balance(t, p, "t1"); after != before {
|
||||
t.Fatalf("重复 tick 不该重复发放:%d → %d", before, after)
|
||||
}
|
||||
assertBalanceInvariant(t, p, "t1")
|
||||
}
|
||||
|
||||
// 过期后一分不发,且状态置 expired(到期即失效,无自动续费)。
|
||||
func TestSubscription_ExpiresAndStopsGranting(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
pl := seedPlan(t, p, 14, 7, 100_000_000) // 14 天,7 天一发 → 最多发第 1、第 7 天两笔
|
||||
ctx := context.Background()
|
||||
|
||||
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
|
||||
now := sub.StartedAt.AddDate(0, 0, 100) // 远超到期
|
||||
granted, expired, err := p.TickSubscription(ctx, sub, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !expired {
|
||||
t.Fatal("早该过期了")
|
||||
}
|
||||
// 到期时间点(第 14 天)之后的周期不发:第 7 天那笔算,第 14 天正好等于到期不算
|
||||
if granted != 1 {
|
||||
t.Fatalf("过期前只应补第 7 天那一笔,得 %d 笔", granted)
|
||||
}
|
||||
if got := balance(t, p, "t1"); got != 200_000_000 {
|
||||
t.Fatalf("首笔 + 第 7 天 = 200e6,得 %d", got)
|
||||
}
|
||||
|
||||
var s Subscription
|
||||
p.db.WithContext(WithoutTenant(ctx)).First(&s, "id = ?", sub.ID)
|
||||
if s.Status != SubExpired {
|
||||
t.Fatalf("状态应为 expired,得 %q", s.Status)
|
||||
}
|
||||
assertBalanceInvariant(t, p, "t1")
|
||||
}
|
||||
|
||||
// 配置校验:间隔比时长长 = 一个周期只发得到首笔,多半是配错了,直接拦。
|
||||
func TestSubPlan_RejectsBadConfig(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
duration, ivl int
|
||||
wantErrSubstring string
|
||||
}{
|
||||
{"时长为 0", 0, 7, "订阅时长"},
|
||||
{"间隔为 0", 30, 0, "发放间隔"},
|
||||
{"间隔大于时长", 7, 30, "不能大于"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := p.SaveSubPlan(ctx, &SubscriptionPlan{
|
||||
Name: "x", DurationDays: tc.duration, RefillIntervalDays: tc.ivl, RefillCreditsMicro: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("应被拒绝")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var _ = time.Now
|
||||
|
||||
// 订阅单经支付回调入账后必须真的开通订阅。这是"钱收了但订阅没生效"的高危点,
|
||||
// 而且开通逻辑刻意放在 MarkOrderPaid 里(回调与掉单补偿两条路共用),这里一并钉死。
|
||||
func TestSubscription_ActivatedByOrderPayment(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
pl := seedPlan(t, p, 30, 7, 100_000_000)
|
||||
ctx := context.Background()
|
||||
|
||||
o := &PaymentOrder{
|
||||
TenantID: "t1", UserID: "u1", Kind: OrderKindSub, PlanID: pl.ID,
|
||||
AmountFen: pl.PriceFen, CreditsMicro: 0, // 订阅单自身不带积分
|
||||
Channel: ChannelWechat, Status: OrderPending,
|
||||
}
|
||||
if err := p.CreateOrder(ctx, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := p.MarkOrderPaid(ctx, o.ID, "txn-1")
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("入账应成功: changed=%v err=%v", changed, err)
|
||||
}
|
||||
|
||||
sub := p.ActiveSubscription(ctx, "t1")
|
||||
if sub == nil {
|
||||
t.Fatal("付款后应已开通订阅")
|
||||
}
|
||||
if sub.OrderID != o.ID {
|
||||
t.Fatalf("订阅应关联来源订单 %s,得 %s", o.ID, sub.OrderID)
|
||||
}
|
||||
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||
t.Fatalf("开通即发首笔,余额应 100e6,得 %d", got)
|
||||
}
|
||||
assertBalanceInvariant(t, p, "t1")
|
||||
|
||||
// 回调重复推送:不能重复开通、不能重复发放
|
||||
if _, err := p.MarkOrderPaid(ctx, o.ID, "txn-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := balance(t, p, "t1"); got != 100_000_000 {
|
||||
t.Fatalf("重复回调不该重复发放,得 %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user