package store import ( "context" "os" "strconv" "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, } // 幂等:同一 task_id 已有明细则不重复插入(防 NATS 重投重复计费)。 return p.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "task_id"}}, DoNothing: true, }).Create(row).Error } // 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 }