f8f7359723
管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期
发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在
配的时候就看见后果,而不是上线后才发现只发了一次。
顺带修了个真 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>
171 lines
6.7 KiB
Go
171 lines
6.7 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// 积分账本分录类型。
|
||
const (
|
||
LedgerGrant = "grant" // 充值 / 发放(+)
|
||
LedgerUsage = "usage" // 用量消耗(−)
|
||
LedgerAdjust = "adjust" // 人工校正(±)
|
||
)
|
||
|
||
// CreditLedger 是积分账本一条分录(append-only)——扣量 / 充值的事实源。
|
||
// 租户当前余额 = SUM(credits_micro);为快取物化在 sundynix_tenant.credit_balance_micro。
|
||
// 表名 sundynix_credit_ledger。
|
||
type CreditLedger struct {
|
||
BaseModel
|
||
TenantID string `gorm:"size:64;index"`
|
||
Kind string `gorm:"size:16"` // grant / usage / adjust
|
||
CreditsMicro int64 `gorm:"column:credits_micro"` // 带符号增量(usage 为负)
|
||
Ref string `gorm:"size:64;index"` // 关联(usage→task_id,grant→订单号)
|
||
Memo string `gorm:"size:255"`
|
||
}
|
||
|
||
func (CreditLedger) TableName() string { return "sundynix_credit_ledger" }
|
||
func (CreditLedger) isTenantScoped() {}
|
||
|
||
// UsageRollup 是用量按 租户/天 的聚合快照(配额 / 账单读一行,不扫明细)。
|
||
// 消费 UsageEvent 时 upsert 累加。表名 sundynix_usage_rollup。
|
||
type UsageRollup struct {
|
||
BaseModel
|
||
TenantID string `gorm:"size:64;uniqueIndex:idx_rollup_td"`
|
||
Day string `gorm:"size:8;uniqueIndex:idx_rollup_td"` // 20060102
|
||
TotalTok int64
|
||
CreditsMicro int64 `gorm:"column:credits_micro"`
|
||
CostMicros int64 `gorm:"column:cost_micros"`
|
||
TaskCount int64
|
||
Currency string `gorm:"size:8"`
|
||
}
|
||
|
||
func (UsageRollup) TableName() string { return "sundynix_usage_rollup" }
|
||
func (UsageRollup) isTenantScoped() {}
|
||
|
||
// applyUsageCredit 在事务内记 usage 分录(负)+ 扣物化余额(软扣,可为负)。
|
||
// 幂等由上层的 usage_event(task_id 唯一) 插入门控保证——仅在新明细插入后调用一次。
|
||
func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64) error {
|
||
if tenantID == "" || creditsMicro <= 0 {
|
||
return nil
|
||
}
|
||
if err := tx.Create(&CreditLedger{
|
||
TenantID: tenantID, Kind: LedgerUsage, CreditsMicro: -creditsMicro, Ref: taskID, Memo: "task usage",
|
||
}).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
||
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", creditsMicro)).Error
|
||
}
|
||
|
||
// upsertRollup 在事务内累加 租户/天 聚合。
|
||
func upsertRollup(tx *gorm.DB, tenantID, day string, tok, creditsMicro, costMicros int64, currency string) error {
|
||
if tenantID == "" {
|
||
return nil
|
||
}
|
||
return tx.Clauses(clause.OnConflict{
|
||
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "day"}},
|
||
DoUpdates: clause.Assignments(map[string]any{
|
||
"total_tok": gorm.Expr("sundynix_usage_rollup.total_tok + ?", tok),
|
||
"credits_micro": gorm.Expr("sundynix_usage_rollup.credits_micro + ?", creditsMicro),
|
||
"cost_micros": gorm.Expr("sundynix_usage_rollup.cost_micros + ?", costMicros),
|
||
"task_count": gorm.Expr("sundynix_usage_rollup.task_count + 1"),
|
||
}),
|
||
}).Create(&UsageRollup{
|
||
TenantID: tenantID, Day: day, TotalTok: tok, CreditsMicro: creditsMicro,
|
||
CostMicros: costMicros, TaskCount: 1, Currency: currency,
|
||
}).Error
|
||
}
|
||
|
||
// UsageDay 是按天聚合的用量(趋势用)。
|
||
type UsageDay struct {
|
||
Day string `json:"day"`
|
||
TotalTok int64 `json:"total_tok"`
|
||
CreditsMicro int64 `json:"credits_micro"`
|
||
CostMicros int64 `json:"cost_micros"`
|
||
TaskCount int64 `json:"task_count"`
|
||
}
|
||
|
||
// UsageTenantSum 是某租户在区间内的用量汇总(+当前余额),供全平台排行。
|
||
type UsageTenantSum struct {
|
||
TenantID string `json:"tenant_id"`
|
||
Name string `json:"name"`
|
||
TotalTok int64 `json:"total_tok"`
|
||
CreditsMicro int64 `json:"credits_micro"`
|
||
CostMicros int64 `json:"cost_micros"`
|
||
TaskCount int64 `json:"task_count"`
|
||
BalanceMicro int64 `json:"balance_micro"`
|
||
}
|
||
|
||
// UsageTrend 返回按天聚合的用量(tenantID 空=全平台跨租户 SUM)。区间 [from,to] 为 YYYYMMDD。
|
||
// 需系统级 ctx(store.WithoutTenant)才跨租户;否则受插件按调用者租户过滤。
|
||
func (p *Postgres) UsageTrend(ctx context.Context, tenantID, from, to string) []UsageDay {
|
||
if p.db == nil {
|
||
return nil
|
||
}
|
||
q := p.db.WithContext(ctx).Model(&UsageRollup{}).
|
||
Select("day, sum(total_tok) as total_tok, sum(credits_micro) as credits_micro, sum(cost_micros) as cost_micros, sum(task_count) as task_count").
|
||
Where("day >= ? AND day <= ?", from, to).Group("day").Order("day")
|
||
if tenantID != "" {
|
||
q = q.Where("tenant_id = ?", tenantID)
|
||
}
|
||
var out []UsageDay
|
||
q.Scan(&out)
|
||
return out
|
||
}
|
||
|
||
// UsageByTenant 返回各租户在区间内的用量汇总(含当前余额),按积分消耗降序,供全平台排行。
|
||
func (p *Postgres) UsageByTenant(ctx context.Context, from, to string, limit int) []UsageTenantSum {
|
||
if p.db == nil {
|
||
return nil
|
||
}
|
||
if limit <= 0 {
|
||
limit = 20
|
||
}
|
||
var out []UsageTenantSum
|
||
p.db.WithContext(ctx).Table("sundynix_usage_rollup r").
|
||
Select("r.tenant_id, t.name, sum(r.total_tok) as total_tok, sum(r.credits_micro) as credits_micro, "+
|
||
"sum(r.cost_micros) as cost_micros, sum(r.task_count) as task_count, "+
|
||
"coalesce(max(t.credit_balance_micro),0) as balance_micro").
|
||
Joins("left join sundynix_tenant t on t.id = r.tenant_id").
|
||
Where("r.day >= ? AND r.day <= ?", from, to).
|
||
Group("r.tenant_id, t.name").Order("credits_micro desc").Limit(limit).Scan(&out)
|
||
return out
|
||
}
|
||
|
||
// TenantBalance 返回某租户当前物化积分余额(微积分)。
|
||
func (p *Postgres) TenantBalance(ctx context.Context, tenantID string) int64 {
|
||
if p.db == nil || tenantID == "" {
|
||
return 0
|
||
}
|
||
var t Tenant
|
||
if err := p.db.WithContext(ctx).Select("credit_balance_micro").First(&t, "id = ?", tenantID).Error; err != nil {
|
||
return 0
|
||
}
|
||
return t.CreditBalanceMicro
|
||
}
|
||
|
||
// GrantCredits 给租户充值 / 发放积分(记 grant 分录 + 增物化余额)。供充值 / 套餐发放 / 人工校正复用。
|
||
func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, creditsMicro int64, ref, memo string) error {
|
||
if p.db == nil || tenantID == "" || creditsMicro == 0 {
|
||
return nil
|
||
}
|
||
if kind == "" {
|
||
kind = LedgerGrant
|
||
}
|
||
// 跨租户管理操作:admin 请求 ctx 带着 admin 自己的租户,须旁路,否则 tenant 插件会把
|
||
// 账本分录的 tenant_id 覆盖成 admin 的租户(余额记到目标租户、分录却记到 admin,破坏对账)。
|
||
ctx = WithoutTenant(ctx)
|
||
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Create(&CreditLedger{
|
||
TenantID: tenantID, Kind: kind, CreditsMicro: creditsMicro, Ref: ref, Memo: memo,
|
||
}).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
||
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", creditsMicro)).Error
|
||
})
|
||
}
|