Files
Blizzard f8f7359723 feat(admin): 订阅管理页 + 修「余额列为 NULL 导致充值永不到账」
管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期
发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在
配的时候就看见后果,而不是上线后才发现只发了一次。

顺带修了个真 bug,是拿真库验订阅时撞出来的(本地 42 个租户里 11 个中招):

  credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL。而入账语句是
  「余额 + N」—— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**:
  分录照写、余额不动、不报错。这条路径是充值/兑换码/退款/扣费/订阅发放共用的,
  不是订阅引入的问题。

三处修:
  - 5 处余额增减一律改 coalesce(credit_balance_micro, 0),新写入自愈;
  - 启动迁移回填存量 NULL(按账本求和,让「余额 = SUM(ledger)」重新成立);
  - 模型只加 default:0,**刻意不加 not null** —— 存量库有 NULL 行,AutoMigrate
    尝试 SET NOT NULL 会直接失败,而且它在回填之前跑,等于把部署搞挂。

回归测试先证明能失败(去掉 coalesce → 余额 0)再确认修复。第一版测试因为我给
模型加了 not null 而无法造出 NULL,恰好暴露了上面那个部署风险。

真库验证:回填后 42 个租户 0 个 NULL;那个"有分录但余额 NULL"的租户余额
2981 = 账本合计 2981.34。管理端页面显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:28:32 +08:00

271 lines
9.0 KiB
Go

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)
}
}
// 余额列为 NULL 时入账必须仍然生效。
// 真实事故:credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
// 而入账语句是「余额 + N」——SQL 里 NULL + N = NULL,于是这些租户**充值永远不到账**,
// 分录照写、余额不动、且不报错。本地库 42 个租户里有 11 个处于此状态。
func TestGrantCredits_SurvivesNullBalance(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t-null")
// 造出"历史租户":把余额置回 NULL
if err := p.db.WithContext(WithoutTenant(ctx)).Exec(
"UPDATE sundynix_tenant SET credit_balance_micro = NULL WHERE id = ?", "t-null").Error; err != nil {
t.Fatal(err)
}
if err := p.GrantCredits(ctx, "t-null", LedgerGrant, 50_000_000, "ref-null", "充值"); err != nil {
t.Fatalf("入账失败: %v", err)
}
if got := balance(t, p, "t-null"); got != 50_000_000 {
t.Fatalf("NULL 余额的租户充值后应为 50e6,得 %d —— coalesce 丢了?", got)
}
assertBalanceInvariant(t, p, "t-null")
}