Files
sundynix-agentix/sundynix-gateway/internal/store/usage.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

118 lines
4.2 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"
"os"
"strconv"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/sundynix/sundynix-shared/contract"
)
// UsageEvent 是一条持久化的任务用量明细(追加式,只增不改)——计费 / 对账 / 重算的事实源。
// 由网关消费 dispatcher 回写的 contract.UsageEvent 落库;tenant/owner 从事件显式带(background ctx 无请求租户)。
// 表名 sundynix_usage_event。对 task_id 唯一 → 幂等(防重投重复计费)。
type UsageEvent struct {
BaseModel
TenantID string `gorm:"size:64;index"`
Owner string `gorm:"size:64;index"` // 提交者 user.id= 事件 UserID
TaskID string `gorm:"size:64;uniqueIndex"` // 一任务一计量 → 幂等键
Model string `gorm:"size:64"` // 计费模型名(空=按激活 chat 模型近似)
PromptTok int
CompTok int
TotalTok int
CreditsMicro int64 `gorm:"column:credits_micro"` // 折算积分 ×10⁻⁶
CostMicros int64 `gorm:"column:cost_micros"` // 折算金额(币种最小单位 ×10⁻⁶)
Currency string `gorm:"size:8"`
Exceeded bool
TS int64
}
func (UsageEvent) TableName() string { return "sundynix_usage_event" }
func (UsageEvent) isTenantScoped() {}
// TokensPerCredit 读 token→积分 汇率(env TOKENS_PER_CREDIT,缺省/非法=1000)。设 1 即 token 直计。
func TokensPerCredit() float64 {
if v := os.Getenv("TOKENS_PER_CREDIT"); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil && n > 0 {
return n
}
}
return 1000
}
// SaveUsageEvent 折算 credits + cost 并幂等落一条用量明细。
// 折算模型:ev.Model 优先;为空则回退当前激活 chat 模型(近似——忽略 failover 到备用模型的情形)。
// 缺 Pricing → cost=0、weight=1(计量不因缺价而丢量,可事后补价重算)。
func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent) error {
if p.db == nil {
return nil
}
model := ev.Model
if model == "" {
if cfg := p.ActiveConfig(ctx, contract.ConfigKindChat); cfg != nil {
model = cfg.Model
}
}
weight := 1.0
currency := ""
var costMicros int64
if pr := p.pricingForModelName(ctx, model); pr != nil {
if pr.CreditWeight > 0 {
weight = pr.CreditWeight
}
currency = pr.Currency
// cost(币种单位)= tok/1000 * per1k;×10⁶ 存微单位(整数)。
cost := float64(ev.PromptTok)/1000*pr.InputPer1K + float64(ev.CompTok)/1000*pr.OutputPer1K
costMicros = int64(cost * 1e6)
}
// credits_micro = total_tok / tokensPerCredit * weight,×10⁶ 存微积分。
creditsMicro := int64(float64(ev.TotalTok) / TokensPerCredit() * weight * 1e6)
row := &UsageEvent{
TenantID: ev.TenantID, Owner: ev.UserID, TaskID: ev.TaskID, Model: model,
PromptTok: ev.PromptTok, CompTok: ev.CompTok, TotalTok: ev.TotalTok,
CreditsMicro: creditsMicro, CostMicros: costMicros, Currency: currency,
Exceeded: ev.Exceeded, TS: ev.TS,
}
day := time.UnixMilli(ev.TS).Format("20060102")
// 一个事务内:落明细 → 扣积分(账本+余额) → 累加 rollup。
// 幂等锚点:usage_event 的 task_id 唯一;插入若被冲突吞掉(RowsAffected==0)→重投,跳过后续,绝不重复计费。
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
res := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "task_id"}},
DoNothing: true,
}).Create(row)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 重投:明细已存在,账本/余额/rollup 均不再动
}
if err := applyUsageCredit(tx, ev.TenantID, ev.TaskID, creditsMicro); err != nil {
return err
}
return upsertRollup(tx, ev.TenantID, day, int64(ev.TotalTok), creditsMicro, costMicros, currency)
})
}
// pricingForModelName 按模型名查计价(join model 表,pricing 以 model_id 关联)。查不到返回 nil。
func (p *Postgres) pricingForModelName(ctx context.Context, name string) *Pricing {
if p.db == nil || name == "" {
return nil
}
var pr Pricing
err := p.db.WithContext(ctx).
Joins("JOIN sundynix_model m ON m.id = sundynix_pricing.model_id").
Where("m.model = ?", name).First(&pr).Error
if err != nil {
return nil
}
return &pr
}