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") }