Files
sundynix-agentix/sundynix-gateway/internal/store/credit.go
T
Blizzard d058a63434 feat(gateway): SaaS P2 计量·增量2 —— 积分账本 + 物化余额 + 用量 rollup
- 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>
2026-07-07 11:26:27 +08:00

100 lines
3.9 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
}
// 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
})
}