feat(gateway): 计费规则可后台配置 —— token→积分汇率(DB) + 每模型积分权重
让「扣费按规则扣」的规则真正可后台热调(此前汇率是 env、积分权重无接口): - sundynix_setting KV 表 + GetSetting/SetSetting;TokensPerCredit 改为 DB 设置优先 → env 回退 → 1000,SaveUsageEvent 按 ctx 读,改完即对后续任务生效。 - Pricing.credit_weight 纳入 UpsertPricing + ListPricing/SavePricing API。 - GET/PUT /admin/billing-config(token→积分汇率)。 live 验证:UI 存汇率=500 → billing-config/DB=500 → 新任务 credits_micro=214000 =107tok/500×1e6,规则→扣费联动精确成立。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -268,15 +268,16 @@ func (p *Postgres) ListPricing(ctx context.Context) ([]Pricing, error) {
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种)。
|
||||
func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K float64, currency string) error {
|
||||
// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种/积分权重)。
|
||||
// creditWeight 为每模型积分权重(0=按 1.0 计,即不加权)。
|
||||
func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K, creditWeight float64, currency string) error {
|
||||
if p.db == nil {
|
||||
return errStoreDisabled
|
||||
}
|
||||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "model_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{"input_per_1k": inPer1K, "output_per_1k": outPer1K, "currency": currency, "updated_at": time.Now()}),
|
||||
}).Create(&Pricing{ModelID: modelID, InputPer1K: inPer1K, OutputPer1K: outPer1K, Currency: currency}).Error
|
||||
DoUpdates: clause.Assignments(map[string]any{"input_per_1k": inPer1K, "output_per_1k": outPer1K, "credit_weight": creditWeight, "currency": currency, "updated_at": time.Now()}),
|
||||
}).Create(&Pricing{ModelID: modelID, InputPer1K: inPer1K, OutputPer1K: outPer1K, CreditWeight: creditWeight, Currency: currency}).Error
|
||||
}
|
||||
|
||||
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
|
||||
|
||||
@@ -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{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}); err != nil {
|
||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Setting 是平台级键值配置(全局,非租户作用域)——如 token→积分汇率 等可后台热调的计费规则。
|
||||
// 表名 sundynix_setting。Key 唯一。
|
||||
type Setting struct {
|
||||
BaseModel
|
||||
Key string `gorm:"size:64;uniqueIndex"`
|
||||
Value string `gorm:"size:255"`
|
||||
}
|
||||
|
||||
func (Setting) TableName() string { return "sundynix_setting" }
|
||||
|
||||
// 平台设置键。
|
||||
const SettingTokensPerCredit = "tokens_per_credit"
|
||||
|
||||
// GetSetting 读一个平台设置;不存在返回空串(调用方回退默认)。
|
||||
func (p *Postgres) GetSetting(ctx context.Context, key string) string {
|
||||
if p.db == nil {
|
||||
return ""
|
||||
}
|
||||
var s Setting
|
||||
if err := p.db.WithContext(ctx).Select("value").Where("key = ?", key).First(&s).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return s.Value
|
||||
}
|
||||
|
||||
// SetSetting 幂等写一个平台设置(key 唯一,重复即覆盖 value)。
|
||||
func (p *Postgres) SetSetting(ctx context.Context, key, value string) error {
|
||||
if p.db == nil {
|
||||
return errStoreDisabled
|
||||
}
|
||||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}),
|
||||
}).Create(&Setting{Key: key, Value: value}).Error
|
||||
}
|
||||
@@ -34,8 +34,21 @@ type UsageEvent struct {
|
||||
func (UsageEvent) TableName() string { return "sundynix_usage_event" }
|
||||
func (UsageEvent) isTenantScoped() {}
|
||||
|
||||
// TokensPerCredit 读 token→积分 汇率(env TOKENS_PER_CREDIT,缺省/非法=1000)。设 1 即 token 直计。
|
||||
func TokensPerCredit() float64 {
|
||||
// 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
|
||||
@@ -71,7 +84,7 @@ func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent)
|
||||
costMicros = int64(cost * 1e6)
|
||||
}
|
||||
// credits_micro = total_tok / tokensPerCredit * weight,×10⁶ 存微积分。
|
||||
creditsMicro := int64(float64(ev.TotalTok) / TokensPerCredit() * weight * 1e6)
|
||||
creditsMicro := int64(float64(ev.TotalTok) / p.tokensPerCredit(ctx) * weight * 1e6)
|
||||
|
||||
row := &UsageEvent{
|
||||
TenantID: ev.TenantID, Owner: ev.UserID, TaskID: ev.TaskID, Model: model,
|
||||
|
||||
Reference in New Issue
Block a user