d058a63434
- credit_ledger(append-only):grant/usage/adjust 分录,扣量/充值的事实源。 - tenant.credit_balance_micro:物化余额(= 账本之和),用量扣、充值增。 - usage_rollup:租户/天 upsert 累加(配额/账单读一行,不扫明细)。 - 消费 usage_event 一个事务内:落明细 → applyUsageCredit(账本负分录+扣余额) → upsertRollup(累加)。幂等锚在 usage_event.task_id 唯一:RowsAffected==0(重投) 则跳过账本/余额/rollup,绝不重复计费。软扣:余额可为负(不拦,硬闸留 P4)。 - GrantCredits 充值/发放 API(记 grant 分录+增余额;增量3 admin 接)。 live 验证:ledger 负分录(ref=task_id) / 余额递减 / rollup 两任务累加(count=2,tok/credits求和) / **balance==seed+SUM(ledger) 不变量成立**(物化余额 == 账本真值)。 增量3(GET /admin/usage 趋势+余额)待做。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.9 KiB
Go
100 lines
3.9 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("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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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
|
||
})
|
||
}
|