Files
sundynix-agentix/sundynix-gateway/internal/store/credit.go
T
Blizzard 93741a5504 feat(gateway): 闭合积分环 —— 充值/发放 + 提交前余额硬拦截(默认关)
让预付费积分成为闭环(发放→消耗→见底拦住):
- POST /admin/credits/grant:给租户充值/发放(正=grant,负=adjust 校正),
  复用 store.GrantCredits(账本分录 + 物化余额,一事务)。
- 提交门控:credit_enforce 开启且租户余额≤0 → 拒绝新任务 402;默认关=软扣不拦。
  开关入 billing-config(sundynix_setting KV,后台可切)。
- 修 bug:GrantCredits 是跨租户管理操作,须 store.WithoutTenant——否则 tenant 插件
  会把账本分录的 tenant_id 覆盖成 admin 自己的租户(余额记目标、分录记 admin,破坏对账)。

live 验证:enforce 关→余额0可提交;开→余额0拒 402;充值后可提交、余额递减;
修复后 grant 分录落到目标租户、balance==SUM(ledger) 不变量成立。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:53:43 +08:00

171 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_idgrant→订单号)
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("credit_balance_micro - ?", 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。
// 需系统级 ctxstore.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("credit_balance_micro + ?", creditsMicro)).Error
})
}