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
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -177,14 +178,14 @@ func (h *Handler) AdminAdjustBalance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
Amount decimal.Decimal `json:"amount" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: amount required")
|
||||
return
|
||||
}
|
||||
if req.Amount == 0 {
|
||||
if req.Amount.IsZero() {
|
||||
resp.Fail(c, http.StatusBadRequest, "amount must not be zero")
|
||||
return
|
||||
}
|
||||
@@ -204,7 +205,7 @@ func (h *Handler) AdminAdjustBalance(c *gin.Context) {
|
||||
return tx.Create(&store.BalanceLog{
|
||||
UserID: u.ID,
|
||||
Change: req.Amount,
|
||||
BalanceAfter: u.Balance + req.Amount,
|
||||
BalanceAfter: u.Balance.Add(req.Amount),
|
||||
Type: store.BalanceTypeAdminAdjust,
|
||||
RefID: ref,
|
||||
Remark: req.Remark,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,7 @@ func (h *Handler) AdminModels(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
used := len(chs) > 0
|
||||
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
||||
needsPricing := used && m.InputPrice.IsZero() && m.OutputPrice.IsZero() && m.CacheReadPrice.IsZero()
|
||||
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
||||
out = append(out, gin.H{
|
||||
"id": m.ID, "name": m.Name,
|
||||
@@ -81,7 +82,7 @@ func (h *Handler) AdminModels(c *gin.Context) {
|
||||
usedIDs[u.ModelID] = true
|
||||
}
|
||||
for _, m := range ms {
|
||||
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
|
||||
if usedIDs[m.ID] && m.InputPrice.IsZero() && m.OutputPrice.IsZero() && m.CacheReadPrice.IsZero() {
|
||||
unpriced++
|
||||
}
|
||||
}
|
||||
@@ -121,16 +122,16 @@ func containsStr(list []string, s string) bool {
|
||||
// AdminCreateModel POST /api/v1/admin/models
|
||||
func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
CacheWritePrice float64 `json:"cache_write_price"`
|
||||
ContextLength int `json:"context_length"`
|
||||
SupportsTools bool `json:"supports_tools"`
|
||||
SupportsVision bool `json:"supports_vision"`
|
||||
SupportsReasoning bool `json:"supports_reasoning"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||||
InputPrice decimal.Decimal `json:"input_price"`
|
||||
OutputPrice decimal.Decimal `json:"output_price"`
|
||||
CacheReadPrice decimal.Decimal `json:"cache_read_price"`
|
||||
CacheWritePrice decimal.Decimal `json:"cache_write_price"`
|
||||
ContextLength int `json:"context_length"`
|
||||
SupportsTools bool `json:"supports_tools"`
|
||||
SupportsVision bool `json:"supports_vision"`
|
||||
SupportsReasoning bool `json:"supports_reasoning"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||||
@@ -159,16 +160,16 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
ContextLength *int `json:"context_length"`
|
||||
SupportsTools *bool `json:"supports_tools"`
|
||||
SupportsVision *bool `json:"supports_vision"`
|
||||
SupportsReasoning *bool `json:"supports_reasoning"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Sort *int `json:"sort"`
|
||||
InputPrice *decimal.Decimal `json:"input_price"`
|
||||
OutputPrice *decimal.Decimal `json:"output_price"`
|
||||
CacheReadPrice *decimal.Decimal `json:"cache_read_price"`
|
||||
CacheWritePrice *decimal.Decimal `json:"cache_write_price"`
|
||||
ContextLength *int `json:"context_length"`
|
||||
SupportsTools *bool `json:"supports_tools"`
|
||||
SupportsVision *bool `json:"supports_vision"`
|
||||
SupportsReasoning *bool `json:"supports_reasoning"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Sort *int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/proxy"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -133,7 +134,7 @@ func (h *Handler) Register(c *gin.Context) {
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
Role: store.RoleUser,
|
||||
Balance: 5.0, // 新用户赠送体验余额(可通过 admin 调整)
|
||||
Balance: decimal.NewFromFloat(5.0), // 新用户赠送体验余额(可通过 admin 调整)
|
||||
Status: store.UserStatusActive,
|
||||
}
|
||||
if req.InviteCode != "" {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/openteam/server/internal/api/middleware"
|
||||
"github.com/openteam/server/internal/app"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,7 @@ func newKeysTestEnv(t *testing.T) *keysTestEnv {
|
||||
sqlDB.Close()
|
||||
})
|
||||
u := store.User{Username: "u1", Email: "u1@x.com", PasswordHash: "x",
|
||||
Role: store.RoleUser, Balance: 100, Status: store.UserStatusActive}
|
||||
Role: store.RoleUser, Balance: decimal.NewFromInt(100), Status: store.UserStatusActive}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -126,7 +127,7 @@ func TestKeysLifecycle(t *testing.T) {
|
||||
func TestKeysUserIsolation(t *testing.T) {
|
||||
e := newKeysTestEnv(t)
|
||||
other := store.User{Username: "u2", Email: "u2@x.com", PasswordHash: "x",
|
||||
Role: store.RoleUser, Balance: 0, Status: store.UserStatusActive}
|
||||
Role: store.RoleUser, Balance: decimal.Zero, Status: store.UserStatusActive}
|
||||
e.db.Create(&other)
|
||||
var k store.APIKey
|
||||
k = store.APIKey{UserID: other.ID, Name: "theirs", KeyHash: "h2", KeyPrefix: "pre",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// UsageSummary GET /api/v1/usage/summary — 今日/本月汇总。
|
||||
@@ -18,7 +19,7 @@ func (h *Handler) UsageSummary(c *gin.Context) {
|
||||
|
||||
var todayReq, monthReq int64
|
||||
var todayTok, monthTok int64
|
||||
var todayCost, monthCost float64
|
||||
var todayCost, monthCost decimal.Decimal
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date = ?", u.ID, today).
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&todayReq, &todayTok, &todayCost)
|
||||
@@ -47,7 +48,7 @@ func (h *Handler) UsageStats(c *gin.Context) {
|
||||
ModelID uint64
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
Cost decimal.Decimal
|
||||
}
|
||||
q.Select("model_id, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("model_id").Scan(&rows)
|
||||
@@ -64,7 +65,7 @@ func (h *Handler) UsageStats(c *gin.Context) {
|
||||
Date string
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
Cost decimal.Decimal
|
||||
}
|
||||
q.Select("date, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("date").Order("date").Scan(&rows)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// UserProfile GET /api/v1/user/profile
|
||||
@@ -16,11 +17,14 @@ func (h *Handler) UserProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
// UserBalance GET /api/v1/user/balance — 余额 + 近 30 日消耗。
|
||||
// 消耗扫 usage_daily(预聚合)而非 usage_logs:语义等价(usage_daily 只含 cost>0 的
|
||||
// 计费行,含 canceled——canceled 请求 token 已实际计费),且不受明细保留期清理影响。
|
||||
func (h *Handler) UserBalance(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
var spent float64
|
||||
h.a.DB.Model(&store.UsageLog{}).
|
||||
Where("user_id = ? AND status = ? AND created_at >= ?", u.ID, store.UsageStatusSuccess, time.Now().Add(-30*24*time.Hour)).
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -30).Format("2006-01-02")
|
||||
var spent decimal.Decimal
|
||||
h.a.DB.Model(&store.UsageDaily{}).
|
||||
Where("user_id = ? AND date >= ?", u.ID, cutoff).
|
||||
Select("COALESCE(SUM(cost),0)").Scan(&spent)
|
||||
resp.OK(c, gin.H{
|
||||
"balance": u.Balance,
|
||||
@@ -39,7 +43,7 @@ func (h *Handler) availableModelCount() int64 {
|
||||
func (h *Handler) todayUsage(c *gin.Context, userID uint64) gin.H {
|
||||
var requests int64
|
||||
var tokens int64
|
||||
var cost float64
|
||||
var cost decimal.Decimal
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
h.a.DB.Model(&store.UsageDaily{}).
|
||||
Where("user_id = ? AND date = ?", userID, today).
|
||||
|
||||
Reference in new issue
Block a user