feat: SaaS P2 计量·增量1 —— 用量按租户落持久明细 + 积分/成本折算
链路打通 tenant → 计费事实源: - 契约:Task.Meta 加 MetaTenantID;UsageEvent 加 TenantID。 - 提交:网关 task.Meta[MetaTenantID]=tenantID(c);dispatcher emitUsage 带租户。 - 明细表 sundynix_usage_event(追加式,task_id 唯一→幂等防重投重复计费): tenant/owner/model/tokens + credits_micro + cost_micros。 - 折算:credits=total_tok/TOKENS_PER_CREDIT×credit_weight(token 基准,设 1 即 token 直计); cost 按 Pricing 折算;Pricing 加 credit_weight 列(每模型积分权重,缺省 1)。 模型名空则回退激活 chat 模型(近似,忽略 failover 备用模型,已在设计标注)。 - 网关 SubscribeUsage 折算落明细(保留 Redis 日计数作快速配额校验)。 live 验证:提交任务→一行 usage_event,tenant 匹配用户租户、 credits=89tok/1000×2×1e6=178000 微积分、cost=45/1000×1+44/1000×2=133000 微元 CNY, 折算数学与幂等键均正确。 设计见 SAAS_P2_DESIGN.md。增量2(credit_ledger 余额软扣 + rollup)待做。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -249,10 +249,11 @@ func (p *Postgres) ListLinks(ctx context.Context, owner, kb string) ([]DocLink,
|
||||
// 表名 sundynix_pricing。ModelID 关联 sundynix_model.id,唯一。
|
||||
type Pricing struct {
|
||||
BaseModel
|
||||
ModelID string `gorm:"size:24;uniqueIndex"` // 关联 sundynix_model.id
|
||||
InputPer1K float64 `gorm:"column:input_per_1k"` // 每 1K 输入 token 单价
|
||||
OutputPer1K float64 `gorm:"column:output_per_1k"` // 每 1K 输出 token 单价
|
||||
Currency string `gorm:"size:8"` // 币种(CNY / USD…)
|
||||
ModelID string `gorm:"size:24;uniqueIndex"` // 关联 sundynix_model.id
|
||||
InputPer1K float64 `gorm:"column:input_per_1k"` // 每 1K 输入 token 单价
|
||||
OutputPer1K float64 `gorm:"column:output_per_1k"` // 每 1K 输出 token 单价
|
||||
Currency string `gorm:"size:8"` // 币种(CNY / USD…)
|
||||
CreditWeight float64 `gorm:"column:credit_weight"` // 积分权重(每 token 烧积分的倍率;0/缺省按 1.0 计)
|
||||
}
|
||||
|
||||
func (Pricing) TableName() string { return "sundynix_pricing" }
|
||||
|
||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
||||
migrateLegacyIntIDs(db)
|
||||
migrateDocLinkToID(db)
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}); err != nil {
|
||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user