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

+68 -18
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/openteam/server/internal/store"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
@@ -75,11 +76,13 @@ func TestBillingDeductsBalanceAtomically(t *testing.T) {
m := seedModel(t, db)
rec := NewRecorder(db)
cost := decimal.NewFromFloat(0.15).Add(decimal.NewFromFloat(0.60)).
Mul(decimal.NewFromInt(1000)).Div(decimal.NewFromInt(1e6)).Round(8)
rec.Record(&store.UsageLog{
TraceID: "bill", UserID: u, ModelID: m, ModelName: "m1",
InputTokens: 1000, OutputTokens: 1000,
InputPrice: 0.15, OutputPrice: 0.60,
Cost: 0.15*1000/1e6 + 0.60*1000/1e6,
InputPrice: decimal.NewFromFloat(0.15), OutputPrice: decimal.NewFromFloat(0.60),
Cost: cost,
Status: store.UsageStatusSuccess,
LatencyMS: 5,
CreatedAt: time.Now().UTC(),
@@ -90,15 +93,15 @@ func TestBillingDeductsBalanceAtomically(t *testing.T) {
if err := db.First(&user, u).Error; err != nil {
t.Fatal(err)
}
want := 1 - (0.15*1000/1e6 + 0.60*1000/1e6)
if diff := user.Balance - want; diff > 1e-9 || diff < -1e-9 {
t.Fatalf("余额应扣到 %.10f,实际 %.10f", want, user.Balance)
want := decimal.NewFromInt(1).Sub(cost)
if !user.Balance.Equal(want) {
t.Fatalf("余额应精确扣到 %s,实际 %s", want, user.Balance)
}
var bl store.BalanceLog
if err := db.Where("user_id = ?", u).First(&bl).Error; err != nil {
t.Fatalf("余额流水应落库: %v", err)
}
if bl.Change >= 0 {
if !bl.Change.IsNegative() {
t.Fatalf("流水应为负数扣费,实际 %v", bl.Change)
}
}
@@ -124,7 +127,7 @@ func openTestDB(t *testing.T) *gorm.DB {
func seedUser(t *testing.T, db *gorm.DB, balance float64) uint64 {
t.Helper()
u := store.User{Username: "u" + fmt.Sprint(time.Now().UnixNano()), Email: "u@x.com",
PasswordHash: "x", Role: store.RoleUser, Balance: balance, Status: store.UserStatusActive}
PasswordHash: "x", Role: store.RoleUser, Balance: decimal.NewFromFloat(balance), Status: store.UserStatusActive}
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
@@ -133,7 +136,7 @@ func seedUser(t *testing.T, db *gorm.DB, balance float64) uint64 {
func seedModel(t *testing.T, db *gorm.DB) uint64 {
t.Helper()
m := store.Model{Name: "m1", InputPrice: 0.15, OutputPrice: 0.60, Enabled: true}
m := store.Model{Name: "m1", InputPrice: decimal.NewFromFloat(0.15), OutputPrice: decimal.NewFromFloat(0.60), Enabled: true}
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
@@ -151,7 +154,7 @@ func TestBillingRefIDImpotency(t *testing.T) {
rec := NewRecorder(db)
mk := func(trace string) *store.UsageLog {
return &store.UsageLog{TraceID: trace, UserID: u, ModelName: "m1",
Status: store.UsageStatusSuccess, Cost: 1, CreatedAt: time.Now().UTC()}
Status: store.UsageStatusSuccess, Cost: decimal.NewFromInt(1), CreatedAt: time.Now().UTC()}
}
rec.Record(mk("dup"))
rec.Record(mk("dup"))
@@ -161,7 +164,7 @@ func TestBillingRefIDImpotency(t *testing.T) {
if err := db.First(&user, u).Error; err != nil {
t.Fatal(err)
}
if user.Balance != 4 {
if !user.Balance.Equal(decimal.NewFromInt(4)) {
t.Errorf("balance = %v, want 4 (重复 trace 不重复扣款)", user.Balance)
}
var n int64
@@ -182,7 +185,7 @@ func TestBillingAggregatesBatch(t *testing.T) {
for i := 0; i < n; i++ {
rec.Record(&store.UsageLog{
TraceID: fmt.Sprintf("agg-%d", i), UserID: u, ModelID: m, ModelName: "m1",
InputTokens: 10, OutputTokens: 5, Cost: 0.01,
InputTokens: 10, OutputTokens: 5, Cost: decimal.NewFromFloat(0.01),
Status: store.UsageStatusSuccess, CreatedAt: time.Now().UTC(),
})
}
@@ -192,7 +195,7 @@ func TestBillingAggregatesBatch(t *testing.T) {
if err := db.First(&user, u).Error; err != nil {
t.Fatal(err)
}
if want := 100 - 0.01*n; user.Balance-want > 1e-9 || user.Balance-want < -1e-9 {
if want := decimal.NewFromInt(100).Sub(decimal.NewFromFloat(0.01).Mul(decimal.NewFromInt(n))); !user.Balance.Equal(want) {
t.Fatalf("余额 = %v,期望 %v", user.Balance, want)
}
var ledgers int64
@@ -208,13 +211,13 @@ func TestBillingAggregatesBatch(t *testing.T) {
if daily[0].Requests != n {
t.Fatalf("日聚合 requests = %d,期望 %d", daily[0].Requests, n)
}
if d := daily[0].Cost - 0.01*n; d > 1e-9 || d < -1e-9 {
t.Fatalf("日聚合 cost = %v,期望 %v", daily[0].Cost, 0.01*n)
if want := decimal.NewFromFloat(0.01).Mul(decimal.NewFromInt(n)); !daily[0].Cost.Equal(want) {
t.Fatalf("日聚合 cost = %v,期望 %v", daily[0].Cost, want)
}
// 流水里的 balance_after 应与扣款后余额一致(批内累计)
var last store.BalanceLog
db.Where("user_id = ?", u).Order("id DESC").First(&last)
if last.BalanceAfter-user.Balance > 1e-9 || user.Balance-last.BalanceAfter > 1e-9 {
if !last.BalanceAfter.Equal(user.Balance) {
t.Fatalf("最后一条流水 balance_after = %v,实际余额 %v", last.BalanceAfter, user.Balance)
}
}
@@ -226,7 +229,7 @@ func TestRecorderShardedByUser(t *testing.T) {
var users []uint64
for i := 0; i < 3; i++ {
u := store.User{Username: fmt.Sprintf("shard-u%d", i), Email: fmt.Sprintf("shard%d@x.com", i),
PasswordHash: "x", Role: store.RoleUser, Balance: 100, Status: store.UserStatusActive}
PasswordHash: "x", Role: store.RoleUser, Balance: decimal.NewFromInt(100), Status: store.UserStatusActive}
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
@@ -239,7 +242,7 @@ func TestRecorderShardedByUser(t *testing.T) {
for _, u := range users {
rec.Record(&store.UsageLog{
TraceID: fmt.Sprintf("u%d-%d", u, i), UserID: u, ModelName: "m1",
Cost: 0.001, Status: store.UsageStatusSuccess, CreatedAt: time.Now().UTC(),
Cost: decimal.NewFromFloat(0.001), Status: store.UsageStatusSuccess, CreatedAt: time.Now().UTC(),
})
}
}
@@ -258,7 +261,7 @@ func TestRecorderShardedByUser(t *testing.T) {
t.Fatalf("用户 %d 流水 %d 条,期望 %d", u, len(logs), per)
}
for i := 1; i < len(logs); i++ {
if logs[i].BalanceAfter > logs[i-1].BalanceAfter {
if logs[i].BalanceAfter.GreaterThan(logs[i-1].BalanceAfter) {
t.Fatalf("用户 %d 的流水余额应单调递减(顺序错乱):%v → %v",
u, logs[i-1].BalanceAfter, logs[i].BalanceAfter)
}
@@ -266,6 +269,53 @@ func TestRecorderShardedByUser(t *testing.T) {
}
}
// 渠道×小时桶:状态计数含 error 行,token/成本只累计可计费行,跨小时分桶。
func TestChannelHourlyBucketAggregated(t *testing.T) {
db := openTestDB(t)
u := seedUser(t, db, 100)
m := seedModel(t, db)
rec := NewRecorder(db)
now := time.Now().UTC()
hour := now.Format("2006-01-02T15")
prevHour := now.Add(-time.Hour).Format("2006-01-02T15")
mk := func(trace, status string, at time.Time, ch uint64) *store.UsageLog {
return &store.UsageLog{TraceID: trace, UserID: u, ModelID: m, ModelName: "m1",
ChannelID: ch, InputTokens: 100, OutputTokens: 50, Cost: decimal.NewFromFloat(0.02),
Status: status, LatencyMS: 200, CreatedAt: at}
}
rec.Record(mk("h1", store.UsageStatusSuccess, now, 7))
rec.Record(mk("h2", store.UsageStatusSuccess, now, 7))
rec.Record(mk("h3", store.UsageStatusError, now, 7))
rec.Record(mk("h4", store.UsageStatusSuccess, now.Add(-time.Hour), 7))
rec.Record(mk("h5", store.UsageStatusSuccess, now, 0)) // channel_id=0 不入桶
rec.Close()
var buckets []store.UsageChannelHourly
db.Where("channel_id = ?", 7).Order("hour DESC").Find(&buckets)
if len(buckets) != 2 {
t.Fatalf("应有两个小时桶,实际 %d", len(buckets))
}
cur, prev := buckets[0], buckets[1]
if cur.Hour != hour || prev.Hour != prevHour {
t.Fatalf("桶小时错误:%q / %q,期望 %q / %q", cur.Hour, prev.Hour, hour, prevHour)
}
if cur.Requests != 3 || cur.Success != 2 || cur.Errors != 1 {
t.Fatalf("当前桶计数 = %d/%d/%d,期望 3/2/1(requests/success/errors)", cur.Requests, cur.Success, cur.Errors)
}
// 只有 success 行计延迟
if cur.LatencySum != 400 || cur.LatencyMax != 200 {
t.Fatalf("延迟聚合 = sum %d max %d,期望 400/200", cur.LatencySum, cur.LatencyMax)
}
// token/成本只累计可计费行(2×success)
if cur.InputTokens != 200 || cur.Cost.String() != "0.04" {
t.Fatalf("token/成本 = %d/%s,期望 200/0.04", cur.InputTokens, cur.Cost)
}
if prev.Requests != 1 || prev.Success != 1 {
t.Fatalf("上一小时桶计数 = %d/%d,期望 1/1", prev.Requests, prev.Success)
}
}
// 分片函数:同一用户恒定同片,且覆盖所有分片。
func TestShardOfStable(t *testing.T) {
rec := NewRecorder(openTestDB(t))