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

+82 -37
View File
@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
"github.com/shopspring/decimal"
)
// AdminStatsOverview GET /api/v1/admin/stats/overview — 运营总览。
@@ -29,7 +30,7 @@ func (h *Handler) AdminStatsOverview(c *gin.Context) {
// 全局今日/本月汇总(跨用户)
var todayReq, monthReq int64
var todayCost, monthCost float64
var todayCost, monthCost decimal.Decimal
var todayTokens, monthTokens int64
h.a.DB.Model(&store.UsageDaily{}).Where("date = ?", today).
Select("COALESCE(SUM(requests),0), COALESCE(SUM(cost),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0)").
@@ -42,7 +43,7 @@ func (h *Handler) AdminStatsOverview(c *gin.Context) {
var days []struct {
Date string
Req int64
Cost float64
Cost decimal.Decimal
}
h.a.DB.Model(&store.UsageDaily{}).
Select("date, SUM(requests) req, SUM(cost) cost").
@@ -64,6 +65,10 @@ func (h *Handler) AdminStatsOverview(c *gin.Context) {
// AdminChannelStats GET /api/v1/admin/channels/stats?window=1h — 按渠道聚合近一段的
// 请求量、成功率、错误码分布与延迟分位。定位"哪条渠道在掉"不该靠翻日志。
//
// 短窗口(5m/1h)扫 usage_logs 保留精确延迟分位与错误码分布;长窗口(6h/24h/7d)
// 走 usage_channel_hourly 小时桶——明细扫 5 万行封顶在 7d 下只剩个尾巴,桶是全量,
// 且不受明细保留期清理影响。长窗口延迟给 avg/max(p50/p95 以 avg 填充,桶里没有分布)。
func (h *Handler) AdminChannelStats(c *gin.Context) {
win := c.DefaultQuery("window", "1h")
d, ok := statWindows[win]
@@ -72,40 +77,11 @@ func (h *Handler) AdminChannelStats(c *gin.Context) {
}
since := time.Now().UTC().Add(-d)
// 只取窗口内最近这么多行,避免大窗口把内存吃光(超出部分不参与分位计算)
const maxRows = 50000
var rows []channelStatRow
h.a.DB.Model(&store.UsageLog{}).
Select("channel_id, status, latency_ms, cost, "+
"(input_tokens + output_tokens + cache_read_tokens) AS tokens, error_code, created_at").
Where("created_at >= ? AND channel_id > 0", since).
Order("id DESC").Limit(maxRows).Scan(&rows)
agg := map[uint64]*chanAgg{}
for _, r := range rows {
a := agg[r.ChannelID]
if a == nil {
a = &chanAgg{lat: make([]int, 0, 64), errs: map[string]int{}}
agg[r.ChannelID] = a
}
a.requests++
a.cost += r.Cost
a.tokens += r.Tokens
switch r.Status {
case store.UsageStatusSuccess:
a.success++
a.lat = append(a.lat, r.LatencyMS) // 分位只按正常完成的请求算,中断/失败的延迟不代表体感
case store.UsageStatusCanceled:
a.canceled++
default:
a.err++
if r.ErrorCode != nil && *r.ErrorCode != "" {
a.errs[*r.ErrorCode]++
}
}
if r.CreatedAt.After(a.lastAt) {
a.lastAt = r.CreatedAt
}
if d > time.Hour {
h.aggChannelStatsHourly(since, agg)
} else {
h.aggChannelStatsRaw(since, agg)
}
var chans []store.Channel
@@ -137,6 +113,75 @@ func (h *Handler) AdminChannelStats(c *gin.Context) {
resp.OK(c, gin.H{"window": win, "since": since, "items": out})
}
// aggChannelStatsRaw 短窗口:扫明细(限最近 5 万行防内存失控),保留精确分位与错误码。
func (h *Handler) aggChannelStatsRaw(since time.Time, agg map[uint64]*chanAgg) {
const maxRows = 50000
var rows []channelStatRow
h.a.DB.Model(&store.UsageLog{}).
Select("channel_id, status, latency_ms, cost, "+
"(input_tokens + output_tokens + cache_read_tokens) AS tokens, error_code, created_at").
Where("created_at >= ? AND channel_id > 0", since).
Order("id DESC").Limit(maxRows).Scan(&rows)
for _, r := range rows {
a := agg[r.ChannelID]
if a == nil {
a = &chanAgg{lat: make([]int, 0, 64), errs: map[string]int{}}
agg[r.ChannelID] = a
}
a.requests++
a.cost = a.cost.Add(r.Cost)
a.tokens += r.Tokens
switch r.Status {
case store.UsageStatusSuccess:
a.success++
a.lat = append(a.lat, r.LatencyMS) // 分位只按正常完成的请求算,中断/失败的延迟不代表体感
case store.UsageStatusCanceled:
a.canceled++
default:
a.err++
if r.ErrorCode != nil && *r.ErrorCode != "" {
a.errs[*r.ErrorCode]++
}
}
if r.CreatedAt.After(a.lastAt) {
a.lastAt = r.CreatedAt
}
}
}
// aggChannelStatsHourly 长窗口:读渠道×小时桶。p50/p95 用 avg 近似(桶里没有延迟分布),
// 错误码分布桶里没有、置空;last_at 取最新桶的起始小时(精度 1h)。
func (h *Handler) aggChannelStatsHourly(since time.Time, agg map[uint64]*chanAgg) {
var rows []store.UsageChannelHourly
// 桶粒度 1h:包含 since 落在的那个小时(边界桶会带上窗口前的一小段,可接受)
h.a.DB.Where("hour >= ?", since.UTC().Format("2006-01-02T15")).Find(&rows)
for _, r := range rows {
a := agg[r.ChannelID]
if a == nil {
a = &chanAgg{lat: make([]int, 0, 64), errs: map[string]int{}}
agg[r.ChannelID] = a
}
a.requests += r.Requests
a.success += r.Success
a.canceled += r.Canceled
a.err += r.Errors
a.cost = a.cost.Add(r.Cost)
a.tokens += r.InputTokens + r.OutputTokens + r.CacheReadTokens
if r.Success > 0 {
avg := int(r.LatencySum / r.Success)
a.lat = append(a.lat, avg)
}
if r.LatencyMax > 0 {
a.lat = append(a.lat, r.LatencyMax) // 参与分位计算,max 取两者较大值
}
if bucketAt, err := time.Parse("2006-01-02T15", r.Hour); err == nil && bucketAt.After(a.lastAt) {
a.lastAt = bucketAt
}
}
}
// statWindows 支持的统计窗口。
var statWindows = map[string]time.Duration{
"5m": 5 * time.Minute,
@@ -150,7 +195,7 @@ type channelStatRow struct {
ChannelID uint64
Status string
LatencyMS int
Cost float64
Cost decimal.Decimal
Tokens int64
ErrorCode *string
CreatedAt time.Time
@@ -158,7 +203,7 @@ type channelStatRow struct {
type chanAgg struct {
requests, success, err, canceled int64
cost float64
cost decimal.Decimal
tokens int64
lat []int
errs map[string]int