feat: 金额 decimal 精确计费、failover 按失败类型封顶、渠道小时桶预聚合、分层守护测试
- 计费:19 个金额字段 float64→decimal.Decimal,DB 列保持 numeric(20,8) 零迁移; API 金额仍为 JSON number(MarshalJSONWithoutQuotes),前端零改动 - failover:保留时间预算,新增 OT_PROXY_FAILOVER_CAPS_NET/SERVER/RATE/QUOTA 按失败类型封顶换渠道次数(429/配额类瞬时失败时间预算形同虚设) - 预聚合:新增 usage_channel_hourlies(schema v10),记账事务内同步 upsert, 长窗口渠道统计走桶不再扫明细,spent_last_30d 改扫 usage_daily,v10 一次性回填幂等 - 守护:internal/arch_test.go 用 go/parser 强制分层依赖(fail-closed)
This commit is contained in:
1 parent
5d6e5bdb04
commit
b2e500ff51
25 files changed
+841
-253
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -207,7 +208,7 @@ func (r *Recorder) bill(tx *gorm.DB, logs []*store.UsageLog) error {
|
||||
billable := make([]*store.UsageLog, 0, len(logs))
|
||||
refs := make([]string, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
if (l.Status != store.UsageStatusSuccess && l.Status != store.UsageStatusCanceled) || l.Cost <= 0 {
|
||||
if (l.Status != store.UsageStatusSuccess && l.Status != store.UsageStatusCanceled) || !l.Cost.IsPositive() {
|
||||
continue
|
||||
}
|
||||
billable = append(billable, l)
|
||||
@@ -250,9 +251,9 @@ func (r *Recorder) bill(tx *gorm.DB, logs []*store.UsageLog) error {
|
||||
ledger := make([]store.BalanceLog, 0, len(billable))
|
||||
for _, uid := range order {
|
||||
rows := byUser[uid]
|
||||
total := 0.0
|
||||
total := decimal.Zero
|
||||
for _, l := range rows {
|
||||
total += l.Cost
|
||||
total = total.Add(l.Cost)
|
||||
}
|
||||
var user store.User
|
||||
if err := tx.First(&user, uid).Error; err != nil {
|
||||
@@ -270,10 +271,10 @@ func (r *Recorder) bill(tx *gorm.DB, logs []*store.UsageLog) error {
|
||||
// 流水里的 balance_after 是批内累计值,与逐行扣款时的语义一致
|
||||
running := user.Balance
|
||||
for _, l := range rows {
|
||||
running -= l.Cost
|
||||
running = running.Sub(l.Cost)
|
||||
ledger = append(ledger, store.BalanceLog{
|
||||
UserID: uid,
|
||||
Change: -l.Cost,
|
||||
Change: l.Cost.Neg(),
|
||||
BalanceAfter: running,
|
||||
Type: store.BalanceTypeUsage,
|
||||
RefID: usageRefID(l.TraceID),
|
||||
@@ -312,7 +313,7 @@ func (r *Recorder) bill(tx *gorm.DB, logs []*store.UsageLog) error {
|
||||
d.InputTokens += l.InputTokens
|
||||
d.OutputTokens += l.OutputTokens
|
||||
d.CacheReadTokens += l.CacheReadTokens
|
||||
d.Cost += l.Cost
|
||||
d.Cost = d.Cost.Add(l.Cost)
|
||||
}
|
||||
for _, k := range dailyOrder {
|
||||
d := agg[k]
|
||||
@@ -329,6 +330,72 @@ func (r *Recorder) bill(tx *gorm.DB, logs []*store.UsageLog) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 渠道×小时桶:与日聚合同事务 upsert。长窗口(6h/24h/7d)的渠道统计从桶里
|
||||
// 读取,不再扫 usage_logs(明细有保留期,桶是完整历史)。计费口径与日聚合一致:
|
||||
// token/成本只累计可计费且未重复的行;状态计数(含 error)计全部行。
|
||||
type chKey struct {
|
||||
ch uint64
|
||||
hour string
|
||||
}
|
||||
chAgg := map[chKey]*store.UsageChannelHourly{}
|
||||
chOrder := make([]chKey, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
if l.ChannelID == 0 {
|
||||
continue
|
||||
}
|
||||
k := chKey{ch: l.ChannelID, hour: l.CreatedAt.UTC().Format("2006-01-02T15")}
|
||||
b, ok := chAgg[k]
|
||||
if !ok {
|
||||
b = &store.UsageChannelHourly{ChannelID: k.ch, Hour: k.hour}
|
||||
chAgg[k] = b
|
||||
chOrder = append(chOrder, k)
|
||||
}
|
||||
b.Requests++
|
||||
switch l.Status {
|
||||
case store.UsageStatusSuccess:
|
||||
b.Success++
|
||||
b.LatencySum += int64(l.LatencyMS)
|
||||
if l.LatencyMS > b.LatencyMax {
|
||||
b.LatencyMax = l.LatencyMS
|
||||
}
|
||||
case store.UsageStatusCanceled:
|
||||
b.Canceled++
|
||||
default:
|
||||
b.Errors++
|
||||
}
|
||||
if l.Cost.IsPositive() && seen[usageRefID(l.TraceID)] {
|
||||
b.InputTokens += l.InputTokens
|
||||
b.OutputTokens += l.OutputTokens
|
||||
b.CacheReadTokens += l.CacheReadTokens
|
||||
b.Cost = b.Cost.Add(l.Cost)
|
||||
}
|
||||
}
|
||||
for _, k := range chOrder {
|
||||
b := chAgg[k]
|
||||
// SQLite 的双参 MAX 是标量取大,PG 用 GREATEST
|
||||
latMax := gorm.Expr("MAX(latency_max, ?)", b.LatencyMax)
|
||||
if tx.Dialector != nil && tx.Dialector.Name() == "postgres" {
|
||||
latMax = gorm.Expr("GREATEST(latency_max, ?)", b.LatencyMax)
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "channel_id"}, {Name: "hour"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"requests": gorm.Expr("requests + ?", b.Requests),
|
||||
"success": gorm.Expr("success + ?", b.Success),
|
||||
"canceled": gorm.Expr("canceled + ?", b.Canceled),
|
||||
"errors": gorm.Expr("errors + ?", b.Errors),
|
||||
"input_tokens": gorm.Expr("input_tokens + ?", b.InputTokens),
|
||||
"output_tokens": gorm.Expr("output_tokens + ?", b.OutputTokens),
|
||||
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", b.CacheReadTokens),
|
||||
"cost": gorm.Expr("cost + ?", b.Cost),
|
||||
"latency_sum": gorm.Expr("latency_sum + ?", b.LatencySum),
|
||||
"latency_max": latMax,
|
||||
}),
|
||||
}).Create(b).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user