Files
Blizzard 1ee1e87371 fix: 状态/用量回写升级 JetStream 持久 —— 堵住漏账(core NATS fire-and-forget)
问题:usage(计费)/status 回写走 core NATS,网关离线/慢消费者/NATS 抖动期间
dispatcher 发的事件直接丢——任务照跑照烧 token,但这次计费凭空消失(漏账),且零重试零对账。
(对比:提交/审批/入库本就 JetStream 持久,唯独回写是 best-effort。)

修复(照 tasks/approvals 套路):
- 新增 JetStream 流 SUNDYNIX_USAGE(MaxAge 72h) / SUNDYNIX_STATUS(24h),捕获 usage.task/status.task。
- PublishUsage/PublishTaskStatus 改 js.Publish(同步等 stream ack);dispatcher+gateway 启动各自 ensure 流。
- ConsumeUsage/ConsumeTaskStatus 持久消费者 + 显式 ack:落库成功 Ack、失败 Nak 重投自愈、脏数据 Term。
- 幂等保证 at-least-once 安全:usage_event.task_id 唯一 + 门控;SaveUsageEvent 返回 inserted,
  仅新插入才累计 Redis 日计数(非幂等旁路,防重投重复累加);UpdateTaskStatus 按 task_id 覆盖幂等。

live 验证(复现原漏账场景):提交任务→立刻杀网关→dispatcher 跑完把 usage 发进持久流
(网关离线,usage_event=0 但流积压 1 条=钱没丢)→重启网关→自动补消费:任务 done、
usage_event 补上、公司A 余额扣 0.098、消费者 num_pending/ack_pending 归零。旧设计下这笔会永久丢失。

eval 回写仍 core NATS(仅观测,低价值,暂不改)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:07:31 +08:00

148 lines
5.3 KiB
Go
Raw Permalink 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→积分 汇率:优先 DB 平台设置(后台可热调),回退 env,再回退 1000。
// 设 1 即 token 直计。
func (p *Postgres) tokensPerCredit(ctx context.Context) float64 {
if p.db != nil {
if v := p.GetSetting(ctx, SettingTokensPerCredit); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil && n > 0 {
return n
}
}
}
return tokensPerCreditEnv()
}
// tokensPerCreditEnv 是 DB 未设时的缺省来源(env TOKENS_PER_CREDIT,非法=1000)。
func tokensPerCreditEnv() 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 并幂等落一条用量明细。返回 inserted(是否本次新插入):
// 供调用方据此只在"新插入"时更新非幂等的旁路(如 Redis 日计数),保证 at-least-once 重投下不重复累计。
// 折算模型:ev.Model 优先;为空则回退当前激活 chat 模型(近似——忽略 failover 到备用模型的情形)。
// 缺 Pricing → cost=0、weight=1(计量不因缺价而丢量,可事后补价重算)。
func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent) (inserted bool, err error) {
if p.db == nil {
return false, 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) / p.tokensPerCredit(ctx) * 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)→重投,跳过后续,绝不重复计费。
err = 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 均不再动
}
inserted = true
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)
})
return inserted, err
}
// RecentUsage 返回某租户最近 n 条用量明细(供用户看"最近消耗")。受租户表→用户 ctx 自动过滤,
// 这里也显式带 tenant_id 双保险。
func (p *Postgres) RecentUsage(ctx context.Context, tenantID string, n int) []UsageEvent {
if p.db == nil || tenantID == "" {
return nil
}
if n <= 0 {
n = 10
}
var out []UsageEvent
p.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Order("ts desc").Limit(n).Find(&out)
return out
}
// 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
}