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:
Sakurasan committed 2026-11-09 00:41:48 +08:00
1 parent 5d6e5bdb04
commit b2e500ff51
25 files changed
+841 -253

No files matched your search

+73 -6
View File
@@ -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
}