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>
This commit is contained in:
Blizzard
2026-07-20 16:28:32 +08:00
parent 80d4aaf4ba
commit f8f7359723
8 changed files with 353 additions and 16 deletions
+2 -2
View File
@@ -57,7 +57,7 @@ func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64)
return err
}
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", creditsMicro)).Error
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", creditsMicro)).Error
}
// upsertRollup 在事务内累加 租户/天 聚合。
@@ -165,6 +165,6 @@ func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, cred
return err
}
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", creditsMicro)).Error
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", creditsMicro)).Error
})
}
+13 -11
View File
@@ -10,9 +10,9 @@ type User struct {
BaseModel
Email string `gorm:"uniqueIndex;size:255"`
Name string `gorm:"size:64"`
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
}
// Task 是一次提交的 Agent 编排任务(DSL)。
@@ -35,8 +35,8 @@ func (Task) isTenantScoped() {}
// Eval 是一次任务的自动化评测结果(dispatcher 评完经 NATS 回写,每任务一条,按 task_id upsert)。
type Eval struct {
BaseModel
TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制)
Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制)
TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制)
Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制)
TaskID string `gorm:"uniqueIndex;size:64"`
Overall float64 // 综合分 [0,1]
Rule float64 // 规则分
@@ -85,12 +85,14 @@ func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" }
// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。
type Tenant struct {
BaseModel
Name string `gorm:"size:128"`
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
Status string `gorm:"size:16;default:active"` // active / suspended
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
Name string `gorm:"size:128"`
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
Status string `gorm:"size:16;default:active"` // active / suspended
// 只给 default**不加 not null**:存量库里这一列有历史 NULL 行,AutoMigrate 若尝试
// SET NOT NULL 会直接失败(且它在回填之前跑)。空值由入账处的 coalesce + 启动回填兜住。
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro;default:0"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
}
func (Tenant) TableName() string { return "sundynix_tenant" }
+3 -3
View File
@@ -188,7 +188,7 @@ func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (*
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", rc.CreditsMicro)).Error; err != nil {
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", rc.CreditsMicro)).Error; err != nil {
return err
}
order = o
@@ -253,7 +253,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", o.CreditsMicro)).Error; err != nil {
return err
}
}
@@ -315,7 +315,7 @@ func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, mem
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", o.CreditsMicro)).Error; err != nil {
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", o.CreditsMicro)).Error; err != nil {
return err
}
changed = true
+10
View File
@@ -80,6 +80,16 @@ func OpenPostgres(dsn string) *Postgres {
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
log.Printf("[store] 账本 adjust/ref 唯一索引创建失败(重复退款兜底闸缺位): %v", err)
}
// 回填历史 NULL 余额。credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
// 而入账用的是 `余额 + N` —— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**
// (分录照写、余额不动),且不报错。代码侧已改 coalesce 自愈,这里把存量一次修平,
// 让「余额 = SUM(ledger)」这条对账不变量重新成立。
if err := db.Exec(`UPDATE sundynix_tenant SET credit_balance_micro = COALESCE(
(SELECT SUM(credits_micro) FROM sundynix_credit_ledger l WHERE l.tenant_id = sundynix_tenant.id), 0)
WHERE credit_balance_micro IS NULL`).Error; err != nil {
log.Printf("[store] 历史 NULL 余额回填失败: %v", err)
}
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
return &Postgres{db: db}
@@ -246,3 +246,25 @@ func TestSubscription_ActivatedByOrderPayment(t *testing.T) {
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")
}