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
@@ -6,4 +6,4 @@ import "github.com/openteam/server/internal/cli"
|
||||
|
||||
func main() {
|
||||
cli.Execute()
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/shopspring/decimal v1.6.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/tiktoken-go/tokenizer v0.8.1
|
||||
|
||||
@@ -112,6 +112,8 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
|
||||
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/openteam/server/internal/pkg/ratelimit"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/openteam/server/internal/usage"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -115,7 +116,7 @@ func (a *App) Seed() error {
|
||||
Email: envOr("OT_ADMIN_EMAIL", "admin@localhost"),
|
||||
PasswordHash: hash,
|
||||
Role: store.RoleAdmin,
|
||||
Balance: 1000, // 初始余额,便于联调;生产由充值/调整决定
|
||||
Balance: decimal.NewFromInt(1000), // 初始余额,便于联调;生产由充值/调整决定
|
||||
Status: store.UserStatusActive,
|
||||
}
|
||||
if err := a.DB.Create(&admin).Error; err != nil {
|
||||
@@ -152,8 +153,8 @@ func (a *App) Seed() error {
|
||||
// 默认模型 + 绑定
|
||||
m := store.Model{
|
||||
Name: a.Cfg.Proxy.DefaultModel,
|
||||
InputPrice: 0.15, // 每百万 token,示例价
|
||||
OutputPrice: 0.60,
|
||||
InputPrice: decimal.NewFromFloat(0.15), // 每百万 token,示例价
|
||||
OutputPrice: decimal.NewFromFloat(0.60),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := a.DB.Create(&m).Error; err == nil {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// 架构守护测试:用 AST 扫描 internal 下所有非测试代码的 import,强制分层依赖规则。
|
||||
//
|
||||
// 目标分层(与实际依赖图一致,违规即测试失败):
|
||||
//
|
||||
// pkg/*、config、store ← 叶子层,不得依赖任何 internal 包
|
||||
// channel / usage / passkey ← 只可依赖 store + pkg/*
|
||||
// proxy ← 只可依赖 store、channel、usage、proxy/*、pkg/*
|
||||
// api ← 不得直接 import channel/usage(必须经 app 持有)
|
||||
// app ← 组合根:channel、config、passkey、pkg/*、store、usage
|
||||
// cli ← 顶层装配,不限制
|
||||
//
|
||||
// 本项目没有 arch-go 这类重型工具,一个 go/parser 扫描(毫秒级)足够守住红线;
|
||||
// 新增包时在这里登记规则,未登记的包默认禁止一切 internal 依赖(fail-closed)。
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const modulePrefix = "github.com/openteam/server/internal/"
|
||||
|
||||
// layerRules 相对 internal 的包目录 → 允许依赖的 internal 包前缀(空切片 = 禁止一切)。
|
||||
// 子包继承最长前缀匹配的规则(如 proxy/convert 按 proxy 的规则校验)。
|
||||
var layerRules = map[string][]string{
|
||||
"config": {},
|
||||
"store": {},
|
||||
"pkg": {},
|
||||
"channel": {"store", "pkg"},
|
||||
"usage": {"store", "pkg"},
|
||||
"passkey": {"store", "pkg"},
|
||||
"proxy": {"store", "channel", "usage", "proxy", "pkg"},
|
||||
"api": {"app", "proxy", "passkey", "store", "api", "pkg"},
|
||||
"app": {"channel", "config", "passkey", "store", "usage", "pkg"},
|
||||
"cli": nil, // nil = 顶层装配,不限制
|
||||
}
|
||||
|
||||
// ruleFor 取目录的生效规则:精确匹配优先,否则最长前缀;未登记的包 fail-closed。
|
||||
func ruleFor(dir string) (allowed []string, unrestricted bool) {
|
||||
if r, ok := layerRules[dir]; ok {
|
||||
return r, r == nil
|
||||
}
|
||||
best := ""
|
||||
for k := range layerRules {
|
||||
if strings.HasPrefix(dir, k+"/") && len(k) > len(best) {
|
||||
best = k
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
r := layerRules[best]
|
||||
return r, r == nil
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func dependsOn(imported, allowed string) bool {
|
||||
return imported == allowed || strings.HasPrefix(imported, allowed+"/")
|
||||
}
|
||||
|
||||
func TestInternalDependencyLayers(t *testing.T) {
|
||||
root := "." // 测试二进制的 cwd 即 server/internal
|
||||
var violations []string
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
if name != "." && (strings.HasPrefix(name, ".") || name == "testdata" || name == "vendor") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.ToSlash(filepath.Dir(path))
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
allowed, unrestricted := ruleFor(dir)
|
||||
if unrestricted {
|
||||
return nil
|
||||
}
|
||||
f, perr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||
if perr != nil {
|
||||
t.Errorf("parse %s: %v", path, perr)
|
||||
return nil
|
||||
}
|
||||
for _, imp := range f.Imports {
|
||||
p := strings.Trim(imp.Path.Value, `"`)
|
||||
if !strings.HasPrefix(p, modulePrefix) {
|
||||
continue
|
||||
}
|
||||
target := strings.TrimPrefix(p, modulePrefix)
|
||||
ok := false
|
||||
for _, a := range allowed {
|
||||
if dependsOn(target, a) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
violations = append(violations, path+" → "+p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
sort.Strings(violations)
|
||||
t.Errorf("internal 分层依赖被破坏(%d 处):\n%s\n新增合法依赖请更新 arch_test.go 的 layerRules",
|
||||
len(violations), strings.Join(violations, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,13 @@ import (
|
||||
"github.com/openteam/server/internal/app"
|
||||
"github.com/openteam/server/internal/config"
|
||||
"github.com/openteam/server/internal/proxy"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// serve 启动 HTTP 服务(默认子命令)。
|
||||
func serve() error {
|
||||
// 金额序列化为 JSON number 而非字符串:前端 types(balance/cost 等均为 number)零改动
|
||||
decimal.MarshalJSONWithoutQuotes = true
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -37,6 +40,7 @@ func serve() error {
|
||||
GWTimeout: cfg.Proxy.Timeout,
|
||||
MaxBodyMB: cfg.Proxy.MaxBodyMB,
|
||||
FailoverBudget: cfg.Proxy.FailoverBudget,
|
||||
FailoverSwitchCaps: cfg.Proxy.FailoverSwitchCaps,
|
||||
StreamHeartbeat: cfg.Proxy.StreamHeartbeat,
|
||||
FirstTokenWait: cfg.Proxy.FirstTokenWait,
|
||||
AffinityTTL: cfg.Proxy.AffinityTTL,
|
||||
|
||||
@@ -81,13 +81,16 @@ type AuthConfig struct {
|
||||
}
|
||||
|
||||
type ProxyConfig struct {
|
||||
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
|
||||
UpstreamBaseURL string // 渠道 base_url 默认值
|
||||
UpstreamKey string // 渠道上游 key 默认值
|
||||
DefaultModel string // 渠道模型导入时使用的模型名
|
||||
Timeout time.Duration
|
||||
MaxBodyMB int // /v1 请求体上限(MB),超限按客户端协议返回 413
|
||||
FailoverBudget time.Duration // 故障转移给"第二个及以后的渠道"留的时间预算,0=不限
|
||||
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
|
||||
UpstreamBaseURL string // 渠道 base_url 默认值
|
||||
UpstreamKey string // 渠道上游 key 默认值
|
||||
DefaultModel string // 渠道模型导入时使用的模型名
|
||||
Timeout time.Duration
|
||||
MaxBodyMB int // /v1 请求体上限(MB),超限按客户端协议返回 413
|
||||
FailoverBudget time.Duration // 故障转移给"第二个及以后的渠道"留的时间预算,0=不限
|
||||
// FailoverSwitchCaps 按失败类型(net|server|rate|quota)限制换渠道次数,0=不限。
|
||||
// 429/配额类失败瞬时返回,时间预算对它们形同虚设,按次数封顶才有意义。
|
||||
FailoverSwitchCaps map[string]int
|
||||
StreamHeartbeat time.Duration // 流式空闲多久发一次 SSE 注释心跳,0=关闭
|
||||
FirstTokenWait time.Duration // 流式放行前等内容的最长时间,0=不限;超时未出内容换下一个渠道
|
||||
AffinityTTL time.Duration // 会话亲和 TTL,0=关闭;同一会话粘住上次应答的渠道以命中上游缓存
|
||||
@@ -159,6 +162,10 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("proxy.timeout", "300s")
|
||||
v.SetDefault("proxy.max_body_mb", 32)
|
||||
v.SetDefault("proxy.failover_budget", "30s")
|
||||
v.SetDefault("proxy.failover_caps.net", 4)
|
||||
v.SetDefault("proxy.failover_caps.server", 3)
|
||||
v.SetDefault("proxy.failover_caps.rate", 2)
|
||||
v.SetDefault("proxy.failover_caps.quota", 3)
|
||||
v.SetDefault("proxy.stream_heartbeat", "15s")
|
||||
v.SetDefault("proxy.first_token_wait", "0s")
|
||||
v.SetDefault("proxy.affinity_ttl", "5m")
|
||||
@@ -206,13 +213,19 @@ func Load() (*Config, error) {
|
||||
SaltLen: v.GetInt("auth.salt_len"),
|
||||
},
|
||||
Proxy: ProxyConfig{
|
||||
DefaultChannelName: v.GetString("proxy.default_channel_name"),
|
||||
UpstreamBaseURL: v.GetString("proxy.upstream_base_url"),
|
||||
UpstreamKey: v.GetString("proxy.upstream_key"),
|
||||
DefaultModel: v.GetString("proxy.default_model"),
|
||||
Timeout: v.GetDuration("proxy.timeout"),
|
||||
MaxBodyMB: v.GetInt("proxy.max_body_mb"),
|
||||
FailoverBudget: v.GetDuration("proxy.failover_budget"),
|
||||
DefaultChannelName: v.GetString("proxy.default_channel_name"),
|
||||
UpstreamBaseURL: v.GetString("proxy.upstream_base_url"),
|
||||
UpstreamKey: v.GetString("proxy.upstream_key"),
|
||||
DefaultModel: v.GetString("proxy.default_model"),
|
||||
Timeout: v.GetDuration("proxy.timeout"),
|
||||
MaxBodyMB: v.GetInt("proxy.max_body_mb"),
|
||||
FailoverBudget: v.GetDuration("proxy.failover_budget"),
|
||||
FailoverSwitchCaps: map[string]int{
|
||||
"net": v.GetInt("proxy.failover_caps.net"),
|
||||
"server": v.GetInt("proxy.failover_caps.server"),
|
||||
"rate": v.GetInt("proxy.failover_caps.rate"),
|
||||
"quota": v.GetInt("proxy.failover_caps.quota"),
|
||||
},
|
||||
StreamHeartbeat: v.GetDuration("proxy.stream_heartbeat"),
|
||||
FirstTokenWait: v.GetDuration("proxy.first_token_wait"),
|
||||
AffinityTTL: v.GetDuration("proxy.affinity_ttl"),
|
||||
|
||||
@@ -46,6 +46,9 @@ type Gateway struct {
|
||||
// 故障转移给"第二个及以后的渠道"留的时间预算;超了就直接返回最后一个错误,
|
||||
// 不限制首个渠道(长回答本来就可能跑很久)。
|
||||
failoverBudget time.Duration
|
||||
// 按失败类型限制换渠道次数(0=不限):429/配额类失败是瞬时返回,
|
||||
// 时间预算对它们形同虚设,按次数封顶才有意义。
|
||||
failoverCaps map[channel.FailKind]int
|
||||
// 流式空闲多久补一行 SSE 注释心跳(0=关闭)。反向代理的空闲超时会掐掉长思考的流。
|
||||
streamHeartbeat time.Duration
|
||||
// 流式放行前等多久内容(0=不限制)。超时未出内容取消本次尝试换下一个渠道。
|
||||
@@ -219,14 +222,36 @@ func contains(list []string, s string) bool {
|
||||
type Options struct {
|
||||
UserRPS int
|
||||
LogRaw bool
|
||||
GWTimeout time.Duration // 上游超时兜底(渠道 timeout_ms 优先)
|
||||
MaxBodyMB int // /v1 请求体上限(MB)
|
||||
FailoverBudget time.Duration // 故障转移重试的时间预算
|
||||
StreamHeartbeat time.Duration // 流式空闲心跳间隔
|
||||
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
|
||||
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
|
||||
ChannelAcquireWait time.Duration // 渠道满载排队时长(0=立刻跳过)
|
||||
AuthCacheTTL time.Duration // 鉴权缓存 TTL(0=关闭)
|
||||
GWTimeout time.Duration // 上游超时兜底(渠道 timeout_ms 优先)
|
||||
MaxBodyMB int // /v1 请求体上限(MB)
|
||||
FailoverBudget time.Duration // 故障转移重试的时间预算
|
||||
FailoverSwitchCaps map[string]int // 按失败类型(net|server|rate|quota)限制换渠道次数,0=不限
|
||||
StreamHeartbeat time.Duration // 流式空闲心跳间隔
|
||||
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
|
||||
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
|
||||
ChannelAcquireWait time.Duration // 渠道满载排队时长(0=立刻跳过)
|
||||
AuthCacheTTL time.Duration // 鉴权缓存 TTL(0=关闭)
|
||||
}
|
||||
|
||||
// failoverCapsFrom 把字符串键的失败类型上限配置转成 FailKind 键;未知键忽略(配置容错)。
|
||||
func failoverCapsFrom(m map[string]int) map[channel.FailKind]int {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[channel.FailKind]int, len(m))
|
||||
for k, v := range m {
|
||||
switch k {
|
||||
case "net":
|
||||
out[channel.FailNet] = v
|
||||
case "server":
|
||||
out[channel.FailServer] = v
|
||||
case "rate":
|
||||
out[channel.FailRate] = v
|
||||
case "quota":
|
||||
out[channel.FailQuota] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, o Options) *Gateway {
|
||||
@@ -241,6 +266,7 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
|
||||
gwTimeout: o.GWTimeout,
|
||||
maxBodyMB: o.MaxBodyMB,
|
||||
failoverBudget: o.FailoverBudget,
|
||||
failoverCaps: failoverCapsFrom(o.FailoverSwitchCaps),
|
||||
streamHeartbeat: o.StreamHeartbeat,
|
||||
firstTokenWait: o.FirstTokenWait,
|
||||
affinityTTL: o.AffinityTTL,
|
||||
@@ -493,7 +519,7 @@ func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
|
||||
|
||||
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
|
||||
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
|
||||
if u.Balance <= 0 {
|
||||
if !u.Balance.IsPositive() {
|
||||
apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/openteam/server/internal/pkg/tokenizer"
|
||||
"github.com/openteam/server/internal/proxy/convert"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// newTraceID 生成请求 trace(用于日志与记账幂等 ref)。
|
||||
@@ -282,13 +283,25 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
// 那不是「上游坏了」(502),而是「我方满了」(503 + Retry-After)。
|
||||
saturated := false
|
||||
startAll := time.Now()
|
||||
// 换渠道次数按失败类型计数:cap 到了就不再为同类失败换渠道(配置见 failover_caps)。
|
||||
switches := map[channel.FailKind]int{}
|
||||
var lastKind channel.FailKind
|
||||
// 从选中的渠道开始遍历,到末尾后再从头遍历到选中渠道之前
|
||||
for offset := 0; offset < len(cands); offset++ {
|
||||
// 重试预算只约束第二个及以后的渠道:首个渠道不受限(长回答本就要跑几分钟),
|
||||
// 但坏渠道一个接一个地等下去会让客户端挂到 N×timeout。
|
||||
if offset > 0 && g.failoverBudget > 0 && time.Since(startAll) > g.failoverBudget {
|
||||
lastBody = []byte("failover budget exhausted: " + string(lastBody))
|
||||
break
|
||||
if offset > 0 {
|
||||
if g.failoverBudget > 0 && time.Since(startAll) > g.failoverBudget {
|
||||
lastBody = []byte("failover budget exhausted: " + string(lastBody))
|
||||
break
|
||||
}
|
||||
// 429/配额类失败瞬时返回,时间预算对它们形同虚设;按同类失败次数封顶。
|
||||
// switches 记该类型已发生的失败次数(= 已换渠道次数),本次是第
|
||||
// switches+1 次换渠道,超过上限即停。
|
||||
if cap := g.failoverCaps[lastKind]; cap > 0 && switches[lastKind] > cap {
|
||||
lastBody = []byte("failover switch cap reached (" + lastKind.String() + "): " + string(lastBody))
|
||||
break
|
||||
}
|
||||
}
|
||||
idx := (startIdx + offset) % len(cands)
|
||||
cand := cands[idx]
|
||||
@@ -314,7 +327,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
}
|
||||
// 失败/成功计数由 proxyOne 就地报:只有它读得到上游响应头(429 的 Retry-After)
|
||||
lastCh = ch
|
||||
done, served, retry, st, b := g.proxyOne(c, ch, plan, stream, sink, sh, clientProto)
|
||||
done, served, retry, st, b, fkind := g.proxyOne(c, ch, plan, stream, sink, sh, clientProto)
|
||||
release()
|
||||
if done {
|
||||
if served && hasAff {
|
||||
@@ -336,6 +349,8 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
return
|
||||
}
|
||||
lastStatus, lastBody = st, b
|
||||
switches[fkind]++
|
||||
lastKind = fkind
|
||||
}
|
||||
// 全部候选都没打出去、且都是被并发满载挡住的:503 让客户端稍后重试,
|
||||
// 而不是 502(那意味着上游故障,客户端通常会直接放弃)。
|
||||
@@ -404,11 +419,13 @@ func streamErrorEvent(proto string, status int, msg string) []byte {
|
||||
// served=该渠道成功应答(会话亲和据此绑定);retry=是否可安全换渠道重试;
|
||||
// status+respBody=失败信息。
|
||||
// sh 为流式容错缓冲(非流式为 nil);sink 为请求级记账 sink。
|
||||
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink, sh *streamHold, clientProto string) (done bool, served bool, retry bool, status int, respBody []byte) {
|
||||
// 最后一个返回值 fkind 是本次尝试的失败类型(仅 retry=true 时被 doProxy 消费,
|
||||
// 用于按失败类型统计换渠道次数)。
|
||||
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink, sh *streamHold, clientProto string) (done bool, served bool, retry bool, status int, respBody []byte, fkind channel.FailKind) {
|
||||
upKey, err := g.ch.UpstreamKey(ch)
|
||||
if err != nil {
|
||||
g.ch.ReportFailure(ch, channel.FailNet, 0)
|
||||
return false, false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key")
|
||||
return false, false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key"), channel.FailNet
|
||||
}
|
||||
|
||||
upBody := plan.body
|
||||
@@ -449,7 +466,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
|
||||
req, err := http.NewRequestWithContext(tryCtx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||
if err != nil {
|
||||
return false, false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
||||
return false, false, false, http.StatusInternalServerError, []byte("failed to build upstream request"), channel.FailNet
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+upKey)
|
||||
@@ -478,10 +495,10 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
// 客户端断开导致的取消:就地记账并终结本次请求(不计入渠道失败,也不换渠道)
|
||||
if c.Request.Context().Err() != nil {
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
return true, false, false, 0, nil
|
||||
return true, false, false, 0, nil, channel.FailNet
|
||||
}
|
||||
g.ch.ReportFailure(ch, channel.FailNet, 0) // 网络错误/超时
|
||||
return false, false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
|
||||
return false, false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error()), channel.FailNet
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -492,9 +509,9 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
kind, retryable := classifyUpstreamError(resp.StatusCode, errBody)
|
||||
if retryable {
|
||||
g.ch.ReportFailure(ch, kind, retryAfter(resp.Header))
|
||||
return false, false, true, resp.StatusCode, errBody
|
||||
return false, false, true, resp.StatusCode, errBody, kind
|
||||
}
|
||||
return false, false, false, resp.StatusCode, errBody
|
||||
return false, false, false, resp.StatusCode, errBody, kind
|
||||
}
|
||||
g.ch.ReportSuccess(ch)
|
||||
|
||||
@@ -510,7 +527,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
asked, got = "流式", "整包响应"
|
||||
}
|
||||
return false, false, true, http.StatusBadGateway,
|
||||
[]byte(ch.Name + " answered a " + asked + " request with " + got + " (didNotStream)")
|
||||
[]byte(ch.Name + " answered a " + asked + " request with " + got + " (didNotStream)"), channel.FailServer
|
||||
}
|
||||
|
||||
if stream {
|
||||
@@ -523,20 +540,20 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
// copyAndCapture 非流式:先读完整上游响应再写客户端——响应头与正文都在"确定成功"后
|
||||
// 才写出,读失败(网络错误/超时)发生在写出之前,可换渠道重试。
|
||||
// 此前实现先写 200 头再读,超时只能给客户端留一个 200 空响应。
|
||||
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.Response, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) (done bool, served bool, retry bool, status int, respBody []byte) {
|
||||
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.Response, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) (done bool, served bool, retry bool, status int, respBody []byte, fkind channel.FailKind) {
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
switch {
|
||||
case c.Request.Context().Err() != nil:
|
||||
// 客户端断开:就地记账终结
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
return true, false, false, 0, nil
|
||||
return true, false, false, 0, nil, channel.FailNet
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
g.ch.ReportFailure(ch, channel.FailNet, 0)
|
||||
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " upstream timeout")
|
||||
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " upstream timeout"), channel.FailNet
|
||||
default:
|
||||
g.ch.ReportFailure(ch, channel.FailNet, 0)
|
||||
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " read error: " + err.Error())
|
||||
return false, false, true, http.StatusBadGateway, []byte(ch.Name + " read error: " + err.Error()), channel.FailNet
|
||||
}
|
||||
}
|
||||
if usageRaw := extractUsage(data); usageRaw != nil && sink != nil {
|
||||
@@ -558,7 +575,7 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.R
|
||||
c.Set("raw_response", capRaw(data, rawResponseCap)) // 上游原始响应(未转换)
|
||||
}
|
||||
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
||||
return true, true, false, 0, nil
|
||||
return true, true, false, 0, nil, channel.FailNet
|
||||
}
|
||||
|
||||
// streamHoldCopy 流式转发:边读上游 SSE 边经 hold 写客户端(放行前缓冲)。
|
||||
@@ -568,7 +585,7 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.R
|
||||
// 失败渠道的 message_start 用量不能计入最终账单。
|
||||
func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Channel, r io.Reader, start time.Time,
|
||||
lineConv func([]byte) []byte, clientProto string, trySink, reqSink *usageSink, onRelease func(), tryCtx context.Context,
|
||||
) (done bool, served bool, retry bool, status int, respBody []byte) {
|
||||
) (done bool, served bool, retry bool, status int, respBody []byte, fkind channel.FailKind) {
|
||||
// 原始响应捕获:仅管理员且开关开启(raw_request 已 set)时累积上游原始行
|
||||
_, capture := c.Get("raw_request")
|
||||
var rawResp strings.Builder
|
||||
@@ -580,9 +597,9 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
|
||||
|
||||
scanner := newSSEScanner(r)
|
||||
releaseSeen := false
|
||||
fail := func(msg string, kind channel.FailKind) (bool, bool, bool, int, []byte) {
|
||||
fail := func(msg string, kind channel.FailKind) (bool, bool, bool, int, []byte, channel.FailKind) {
|
||||
g.ch.ReportFailure(ch, kind, 0)
|
||||
return false, false, true, http.StatusBadGateway, []byte(ch.Name + ": " + msg)
|
||||
return false, false, true, http.StatusBadGateway, []byte(ch.Name + ": " + msg), kind
|
||||
}
|
||||
for {
|
||||
line, err := scanner.Next()
|
||||
@@ -605,7 +622,7 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
|
||||
// 客户端意外断开:按已生成部分收费(canceled)
|
||||
commitRaw()
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
return true, false, false, 0, nil
|
||||
return true, false, false, 0, nil, channel.FailNet
|
||||
}
|
||||
if trySink != nil {
|
||||
trySink.addContent(out)
|
||||
@@ -639,12 +656,12 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
|
||||
} else {
|
||||
g.recordError(c, ch, nil, start, "stream_read_error")
|
||||
}
|
||||
return true, err == io.EOF, false, 0, nil
|
||||
return true, err == io.EOF, false, 0, nil, channel.FailNet
|
||||
}
|
||||
// 放行前失败:换渠道重试
|
||||
if c.Request.Context().Err() != nil {
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
return true, false, false, 0, nil
|
||||
return true, false, false, 0, nil, channel.FailNet
|
||||
}
|
||||
if tryCtx != nil && tryCtx.Err() != nil {
|
||||
return fail(fmt.Sprintf("first token timeout (no content in %s)", g.firstTokenWait), channel.FailNet)
|
||||
@@ -1018,7 +1035,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
}
|
||||
|
||||
var model store.Model
|
||||
var cost float64
|
||||
var cost decimal.Decimal
|
||||
var modelID uint64
|
||||
model, _ = g.modelByName(mn)
|
||||
if model.ID > 0 {
|
||||
@@ -1026,12 +1043,18 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
// 计价口径:in=非缓存输入、cacheRead=缓存读、cacheCreate=缓存写(Anthropic 语义,
|
||||
// messages 流式 message_delta 的 input_tokens 即非缓存部分)。
|
||||
// 缓存写价未单独配置时按输入价 1.25×(Anthropic 5m 口径)。
|
||||
// 金额用 decimal 精确运算:token×价/1e6,结果对齐 numeric(20,8) 列精度。
|
||||
cwp := model.CacheWritePriceOr()
|
||||
cost = float64(in)/1e6*model.InputPrice +
|
||||
float64(out)/1e6*model.OutputPrice +
|
||||
float64(cacheRead)/1e6*model.CacheReadPrice +
|
||||
float64(cache5m)/1e6*cwp +
|
||||
float64(cache1h)/1e6*cwp*1.6
|
||||
million := decimal.NewFromInt(1e6)
|
||||
perMillion := func(tokens int64, price decimal.Decimal) decimal.Decimal {
|
||||
return decimal.NewFromInt(tokens).Mul(price).Div(million)
|
||||
}
|
||||
cost = perMillion(in, model.InputPrice).
|
||||
Add(perMillion(out, model.OutputPrice)).
|
||||
Add(perMillion(cacheRead, model.CacheReadPrice)).
|
||||
Add(perMillion(cache5m, cwp)).
|
||||
Add(perMillion(cache1h, cwp.Mul(decimal.NewFromFloat(1.6)))).
|
||||
Round(8)
|
||||
} else {
|
||||
// 模型在记账时已被删除(请求进行中被下架):无法定价,按 0 记账留痕。
|
||||
// 虚构单价扣用户的钱既不可审计也无法对账。
|
||||
@@ -1111,7 +1134,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
})
|
||||
|
||||
// 请求级一行日志:usage_logs 落库是异步的且可能丢,排障时日志是唯一即时依据
|
||||
accessf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%.6f status=%s http=%d latency=%dms err=%s",
|
||||
accessf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%s status=%s http=%d latency=%dms err=%s",
|
||||
traceStr, uidVal, kidVal, mn, servedModel, chID, p, in, out, reasoning, cacheRead, cacheCreate, cache1h, cost, status,
|
||||
c.Writer.Status(), latency, errCode)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,11 +15,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/channel"
|
||||
"github.com/openteam/server/internal/pkg/apikey"
|
||||
"github.com/openteam/server/internal/pkg/crypto"
|
||||
"github.com/openteam/server/internal/pkg/ratelimit"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/openteam/server/internal/usage"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -46,7 +49,7 @@ func newHarness(t *testing.T, baseURL string, timeoutMS int, bind bool, keyModel
|
||||
enc := crypto.NewEncryptor("test-master-key-1234567890")
|
||||
encKey, _ := enc.Encrypt("upstream-key")
|
||||
u := store.User{Username: "u1", Email: "u1@x.com", PasswordHash: "x", Role: store.RoleUser,
|
||||
Balance: 100, Status: store.UserStatusActive}
|
||||
Balance: decimal.NewFromInt(100), Status: store.UserStatusActive}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func newHarness(t *testing.T, baseURL string, timeoutMS int, bind bool, keyModel
|
||||
if err := db.Create(&k).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := store.Model{Name: "m1", InputPrice: 0.15, OutputPrice: 0.6, Enabled: true}
|
||||
m := store.Model{Name: "m1", InputPrice: decimal.NewFromFloat(0.15), OutputPrice: decimal.NewFromFloat(0.6), Enabled: true}
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -426,6 +429,86 @@ func TestFailoverBudgetStopsRetrying(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 429/配额类失败瞬时返回,时间预算对它们形同虚设;按失败类型封顶换渠道次数。
|
||||
// rate 上限 2 → 首渠道 + 至多 2 次换渠道 = 3 次尝试即止;上限 0 = 不限(4 条全试)。
|
||||
func TestFailoverSwitchCaps(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = io.WriteString(w, `{"error":{"message":"rate limited, try again"}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
newMultiChannel := func(t *testing.T, n int) *harness {
|
||||
t.Helper()
|
||||
h := newHarness(t, srv.URL, 0, true, nil)
|
||||
var m store.Model
|
||||
h.db.Where("name = ?", "m1").First(&m)
|
||||
encKey, _ := crypto.NewEncryptor("test-master-key-1234567890").Encrypt("upstream-key")
|
||||
for i := 2; i <= n; i++ {
|
||||
ch := store.Channel{Name: fmt.Sprintf("c%d", i), Provider: store.ChannelProviderOpenAI,
|
||||
Formats: []string{store.FormatChat}, BaseURL: srv.URL, APIKeyEnc: encKey,
|
||||
Weight: 1, TimeoutMS: 0, MaxConcurrency: 4,
|
||||
HealthStatus: store.ChannelHealthHealthy, Enabled: true}
|
||||
if err := h.db.Create(&ch).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.db.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: "m1"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
body := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
|
||||
|
||||
// rate 上限 2:打满即停,第 4 条渠道不应被碰
|
||||
h := newMultiChannel(t, 4)
|
||||
h.gw.failoverCaps = map[channel.FailKind]int{channel.FailRate: 2}
|
||||
w := h.run(t, nil, body)
|
||||
if hits.Load() != 3 {
|
||||
t.Fatalf("rate 上限 2 应恰好尝试 3 条渠道(1+2),实际 %d", hits.Load())
|
||||
}
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("应透传最后的 429,实际 %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "failover switch cap reached (rate_limited)") {
|
||||
t.Fatalf("错误体应说明按类型封顶:%s", w.Body.String())
|
||||
}
|
||||
|
||||
// 上限 0 = 不限:4 条渠道全部试完
|
||||
hits.Store(0)
|
||||
h.gw.failoverCaps = map[channel.FailKind]int{channel.FailRate: 0}
|
||||
if w := h.run(t, nil, body); w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("应 429,实际 %d", w.Code)
|
||||
}
|
||||
if hits.Load() != 4 {
|
||||
t.Fatalf("不限次时应尝试全部 4 条渠道,实际 %d", hits.Load())
|
||||
}
|
||||
|
||||
// quota 上限 1:402 打满即停(402 归为 FailQuota,第一次失败即长冷却,但换渠道仍受上限约束)
|
||||
hits.Store(0)
|
||||
h2 := newMultiChannel(t, 4)
|
||||
h2.gw.failoverCaps = map[channel.FailKind]int{channel.FailQuota: 1}
|
||||
quotaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
_, _ = io.WriteString(w, `{"error":{"message":"insufficient balance"}}`)
|
||||
}))
|
||||
defer quotaSrv.Close()
|
||||
if err := h2.db.Model(&store.Channel{}).Where("1=1").Update("base_url", quotaSrv.URL).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w2 := h2.run(t, nil, body)
|
||||
if hits.Load() != 2 {
|
||||
t.Fatalf("quota 上限 1 应恰好尝试 2 条渠道,实际 %d", hits.Load())
|
||||
}
|
||||
if w2.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("应透传 402,实际 %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 流式空闲时补 SSE 注释心跳:上游长时间不出字(长思考)不能被反代空闲超时掐掉。
|
||||
func TestStreamHeartbeatWhileIdle(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -542,7 +625,7 @@ func TestCacheWritePriceBilledSeparately(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
reqBody := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
|
||||
costOf := func(h *harness) float64 {
|
||||
costOf := func(h *harness) decimal.Decimal {
|
||||
if w := h.run(t, nil, reqBody); w.Code != http.StatusOK {
|
||||
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -560,7 +643,7 @@ func TestCacheWritePriceBilledSeparately(t *testing.T) {
|
||||
// 默认未配置:回退输入价 0.15 × 1.25 = 0.1875,加 in/out 的零头
|
||||
h1 := newHarness(t, srv.URL, 0, true, nil)
|
||||
fallback := costOf(h1)
|
||||
if fallback < 0.18 || fallback > 0.20 {
|
||||
if fallback.LessThan(decimal.NewFromFloat(0.18)) || fallback.GreaterThan(decimal.NewFromFloat(0.20)) {
|
||||
t.Fatalf("未配置缓存写价应按输入价×1.25,实际 cost=%v", fallback)
|
||||
}
|
||||
|
||||
@@ -571,10 +654,10 @@ func TestCacheWritePriceBilledSeparately(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit := costOf(h2)
|
||||
if explicit < 1.99 || explicit > 2.02 {
|
||||
if explicit.LessThan(decimal.NewFromFloat(1.99)) || explicit.GreaterThan(decimal.NewFromFloat(2.02)) {
|
||||
t.Fatalf("按 cache_write_price=2.0 计费应为 ~2.0,实际 %v", explicit)
|
||||
}
|
||||
if !(explicit > fallback) {
|
||||
if !explicit.GreaterThan(fallback) {
|
||||
t.Fatalf("显式价应高于回退价:%v vs %v", explicit, fallback)
|
||||
}
|
||||
}
|
||||
@@ -601,7 +684,7 @@ func TestAllChannelsFailedAttributesLastChannel(t *testing.T) {
|
||||
if l.ChannelID == 0 {
|
||||
t.Fatalf("失败记录没归到渠道上:%+v", l)
|
||||
}
|
||||
if l.Status != store.UsageStatusError || l.Cost != 0 {
|
||||
if l.Status != store.UsageStatusError || !l.Cost.IsZero() {
|
||||
t.Fatalf("失败不应产生扣费:%+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -9,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// runPath 走完整链路(Auth → Handle),路径与请求体由调用方给。
|
||||
@@ -54,8 +54,8 @@ func TestUsageReasoningAndServedModelRecorded(t *testing.T) {
|
||||
t.Fatalf("reasoning_tokens 应为 3,实际 %d", l.ReasoningTokens)
|
||||
}
|
||||
// 计费不含 reasoning 加成:10×0.15/1e6 + 5×0.6/1e6 = 4.5e-6
|
||||
if math.Abs(l.Cost-4.5e-6) > 1e-12 {
|
||||
t.Fatalf("cost 应为 4.5e-6(reasoning 不重复计费),实际 %g", l.Cost)
|
||||
if !l.Cost.Equal(decimal.NewFromFloat(4.5e-6)) {
|
||||
t.Fatalf("cost 应为 4.5e-6(reasoning 不重复计费),实际 %s", l.Cost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,13 @@ func TestAnthropicCache1hBillingSplit(t *testing.T) {
|
||||
t.Fatalf("served_model 应为 claude-x,实际 %q", l.ServedModel)
|
||||
}
|
||||
// 100/1e6*1 + 10/1e6*2 + 200/1e6*0.1 + 100/1e6*1.25 + 200/1e6*(1.25*1.6) = 6.65e-4
|
||||
want := 100.0/1e6*1.0 + 10.0/1e6*2.0 + 200.0/1e6*0.1 + 100.0/1e6*1.25 + 200.0/1e6*2.0
|
||||
if math.Abs(l.Cost-want) > 1e-9 {
|
||||
t.Fatalf("cost 应为 %g(1h/5m 分开计价),实际 %g", want, l.Cost)
|
||||
million := decimal.NewFromInt(1e6)
|
||||
want := decimal.NewFromInt(100).Div(million).Add(decimal.NewFromInt(10).Mul(decimal.NewFromFloat(2)).Div(million)).
|
||||
Add(decimal.NewFromInt(200).Mul(decimal.NewFromFloat(0.1)).Div(million)).
|
||||
Add(decimal.NewFromInt(100).Mul(decimal.NewFromFloat(1.25)).Div(million)).
|
||||
Add(decimal.NewFromInt(200).Mul(decimal.NewFromFloat(2.0)).Div(million))
|
||||
if !l.Cost.Equal(want) {
|
||||
t.Fatalf("cost 应为 %s(1h/5m 分开计价),实际 %s", want, l.Cost)
|
||||
}
|
||||
// 落库 input 为含缓存的总量口径
|
||||
if l.InputTokens != 600 || l.OutputTokens != 10 {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// v10 回填:存量 usage_logs 按 (channel_id, 小时) 聚合进桶;标记幂等(二次执行不重复累计)。
|
||||
func TestBackfillChannelHourly(t *testing.T) {
|
||||
db, err := Open("sqlite", "/tmp/store_backfill_test_"+time.Now().Format("150405.000000000")+".db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
})
|
||||
// Open 已跑过一次空回填并打标;清掉标记与空桶,注入历史明细后重放
|
||||
db.Where("key = ?", "channel_hourly_backfill_v10").Delete(&SystemConfig{})
|
||||
db.Exec("DELETE FROM usage_channel_hourlies")
|
||||
|
||||
at := time.Date(2026, 11, 9, 14, 30, 0, 0, time.UTC)
|
||||
atPrev := at.Add(-2 * time.Hour)
|
||||
rows := []UsageLog{
|
||||
{TraceID: "b1", UserID: 1, ChannelID: 5, Status: UsageStatusSuccess, LatencyMS: 300,
|
||||
InputTokens: 10, OutputTokens: 5, Cost: decimal.NewFromFloat(0.01), CreatedAt: at},
|
||||
{TraceID: "b2", UserID: 1, ChannelID: 5, Status: UsageStatusError, ErrorCode: strp("upstream_http_502"),
|
||||
CreatedAt: at},
|
||||
{TraceID: "b3", UserID: 1, ChannelID: 5, Status: UsageStatusSuccess, LatencyMS: 100,
|
||||
InputTokens: 20, OutputTokens: 10, Cost: decimal.NewFromFloat(0.02), CreatedAt: atPrev},
|
||||
{TraceID: "b4", UserID: 1, ChannelID: 0, Status: UsageStatusSuccess, CreatedAt: at}, // channel 0 不入桶
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backfillChannelHourly(db)
|
||||
|
||||
var buckets []UsageChannelHourly
|
||||
db.Where("channel_id = ?", 5).Order("hour DESC").Find(&buckets)
|
||||
if len(buckets) != 2 {
|
||||
t.Fatalf("应有两个小时桶,实际 %d", len(buckets))
|
||||
}
|
||||
cur, prev := buckets[0], buckets[1]
|
||||
if cur.Requests != 2 || cur.Success != 1 || cur.Errors != 1 || cur.LatencyMax != 300 {
|
||||
t.Fatalf("当前桶 = %+v", cur)
|
||||
}
|
||||
if prev.Requests != 1 || prev.Cost.String() != "0.02" {
|
||||
t.Fatalf("上一桶 = %+v", prev)
|
||||
}
|
||||
|
||||
// 幂等:清标记重放,计数不应翻倍
|
||||
db.Where("key = ?", "channel_hourly_backfill_v10").Delete(&SystemConfig{})
|
||||
backfillChannelHourly(db)
|
||||
var total int64
|
||||
db.Model(&UsageChannelHourly{}).Where("channel_id = ?", 5).Select("COALESCE(SUM(requests),0)").Scan(&total)
|
||||
if total != 3 {
|
||||
t.Fatalf("回填应幂等,requests 总计 = %d,期望 3", total)
|
||||
}
|
||||
}
|
||||
|
||||
func strp(s string) *string { return &s }
|
||||
@@ -28,7 +28,8 @@ import (
|
||||
// v7:usage_logs 加 reasoning_tokens / cache_write_1h_tokens / served_model 三列。
|
||||
// v8:去掉 usage_logs 上没人查询的 key_id / trace_id 索引(每行两次多余的 B-tree 插入)。
|
||||
// v9:同样去掉没人查询的 idx_channel_created。
|
||||
const schemaVersion = 9
|
||||
// v10:新增 usage_channel_hourly(渠道×小时预聚合),并从 usage_logs 一次性回填历史。
|
||||
const schemaVersion = 10
|
||||
|
||||
// Open 打开数据库连接并自动迁移。
|
||||
// 开发默认 SQLite(自动追加 WAL 与 busy_timeout,见 withSQLitePragmas),生产可切 postgres。
|
||||
@@ -67,6 +68,7 @@ func Open(driver, dsn string) (*gorm.DB, error) {
|
||||
}
|
||||
reshapeUsageLogIndexes(db)
|
||||
dropUnusedUsageLogIndexes(db)
|
||||
backfillChannelHourly(db)
|
||||
if driver != "postgres" {
|
||||
setSQLiteVersion(db, schemaVersion)
|
||||
}
|
||||
@@ -102,6 +104,47 @@ func reshapeUsageLogIndexes(db *gorm.DB) {
|
||||
log.Printf("store: usage_logs indexes reshaped (user+created / channel+created)")
|
||||
}
|
||||
|
||||
// backfillChannelHourly v10 一次性回填:把存量 usage_logs 按 (channel_id, 小时) 聚合进
|
||||
// usage_channel_hourlies(GORM 复数表名)。system_configs 打标(PG 每次启动都走迁移路径,
|
||||
// 幂等必须靠标记),SQLite 靠 user_version 门控只跑一次,双保险。
|
||||
// 保留期清理会删老明细,明细没了历史桶仍在——这正是预聚合的意义。
|
||||
func backfillChannelHourly(db *gorm.DB) {
|
||||
var done string
|
||||
db.Model(&SystemConfig{}).Where("key = ?", "channel_hourly_backfill_v10").Pluck("value", &done)
|
||||
if done != "" {
|
||||
return
|
||||
}
|
||||
// 一条 INSERT...SELECT 完成聚合;小时表达式按方言取(SQLite 无 to_char、PG 无 strftime)。
|
||||
// ON CONFLICT 兜底并发/重入:理论上标记保证只跑一次,撞上时增量语义仍正确。
|
||||
hourExpr := `strftime('%Y-%m-%dT%H', created_at)`
|
||||
if db.Dialector != nil && db.Dialector.Name() == "postgres" {
|
||||
hourExpr = `to_char(created_at, 'YYYY-MM-DD"T"HH24')`
|
||||
}
|
||||
res := db.Exec(`INSERT INTO usage_channel_hourlies
|
||||
(channel_id, hour, requests, success, canceled, errors,
|
||||
input_tokens, output_tokens, cache_read_tokens, cost, latency_sum, latency_max)
|
||||
SELECT channel_id,
|
||||
` + hourExpr + ` AS hour,
|
||||
COUNT(*),
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status NOT IN ('success','canceled') THEN 1 ELSE 0 END),
|
||||
SUM(input_tokens), SUM(output_tokens), SUM(cache_read_tokens),
|
||||
COALESCE(SUM(cost), 0),
|
||||
SUM(CASE WHEN status = 'success' THEN latency_ms ELSE 0 END),
|
||||
COALESCE(MAX(CASE WHEN status = 'success' THEN latency_ms ELSE 0 END), 0)
|
||||
FROM usage_logs
|
||||
WHERE channel_id > 0
|
||||
GROUP BY channel_id, hour
|
||||
ON CONFLICT (channel_id, hour) DO NOTHING`)
|
||||
if res.Error != nil {
|
||||
log.Printf("store: backfill usage_channel_hourly: %v", res.Error)
|
||||
return
|
||||
}
|
||||
db.Create(&SystemConfig{Key: "channel_hourly_backfill_v10", Value: `"done"`})
|
||||
log.Printf("store: channel hourly buckets backfilled (%d rows)", res.RowsAffected)
|
||||
}
|
||||
|
||||
// withSQLitePragmas 给 DSN 补上 busy_timeout 与 WAL。
|
||||
// glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数——
|
||||
// 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。
|
||||
|
||||
+103
-80
@@ -1,11 +1,14 @@
|
||||
// Package store 数据模型与仓储层(GORM)。
|
||||
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
|
||||
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8)(Go 侧 decimal.Decimal 精确运算,
|
||||
// 不允许 float64 参与金额计算),token bigint,时间 UTC。
|
||||
package store
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// 角色 / 状态枚举(字符串存库,便于阅读与迁移)
|
||||
@@ -49,20 +52,20 @@ const (
|
||||
|
||||
// User 用户(PLANNING §6.1)
|
||||
type User struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
||||
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
|
||||
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||||
TokenVersion int `gorm:"not null;default:0" json:"-"` // 会话版本:改密/登出递增,低于当前版本的 token 全部失效
|
||||
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"` // 用户级模型白名单(空=不限制)
|
||||
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"` // 用户级模型黑名单
|
||||
InviteCode *string `json:"invite_code,omitempty"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
||||
Balance decimal.Decimal `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
|
||||
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||||
TokenVersion int `gorm:"not null;default:0" json:"-"` // 会话版本:改密/登出递增,低于当前版本的 token 全部失效
|
||||
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"` // 用户级模型白名单(空=不限制)
|
||||
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"` // 用户级模型黑名单
|
||||
InviteCode *string `json:"invite_code,omitempty"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// APIKey 密钥(PLANNING §6.2):库中只存 SHA-256 哈希 + 展示前缀
|
||||
@@ -144,30 +147,30 @@ func (c *Channel) UpstreamURL(proto, path string) string {
|
||||
|
||||
// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
|
||||
type Model struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
||||
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
||||
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
||||
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
||||
InputPrice decimal.Decimal `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
||||
OutputPrice decimal.Decimal `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
||||
CacheReadPrice decimal.Decimal `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
||||
// 缓存写价(每百万 token)。为 0 时按输入价 ×1.25 计(Anthropic 5m 口径),
|
||||
// 显式配置它才能支持 1h 缓存(×2)这类不同倍率。
|
||||
CacheWritePrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_write_price"`
|
||||
ContextLength int `gorm:"not null;default:0" json:"context_length"` // 上下文窗口(token),0=未知
|
||||
SupportsTools bool `gorm:"not null;default:false" json:"supports_tools"`
|
||||
SupportsVision bool `gorm:"not null;default:false" json:"supports_vision"`
|
||||
SupportsReasoning bool `gorm:"not null;default:false" json:"supports_reasoning"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
Sort int `gorm:"not null;default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CacheWritePrice decimal.Decimal `gorm:"type:numeric(20,8);not null;default:0" json:"cache_write_price"`
|
||||
ContextLength int `gorm:"not null;default:0" json:"context_length"` // 上下文窗口(token),0=未知
|
||||
SupportsTools bool `gorm:"not null;default:false" json:"supports_tools"`
|
||||
SupportsVision bool `gorm:"not null;default:false" json:"supports_vision"`
|
||||
SupportsReasoning bool `gorm:"not null;default:false" json:"supports_reasoning"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
Sort int `gorm:"not null;default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CacheWritePriceOr 返回缓存写价;未配置时回退到输入价×1.25(Anthropic 5m 口径)。
|
||||
func (m *Model) CacheWritePriceOr() float64 {
|
||||
if m.CacheWritePrice > 0 {
|
||||
func (m *Model) CacheWritePriceOr() decimal.Decimal {
|
||||
if m.CacheWritePrice.IsPositive() {
|
||||
return m.CacheWritePrice
|
||||
}
|
||||
return m.InputPrice * 1.25
|
||||
return m.InputPrice.Mul(decimal.NewFromFloat(1.25))
|
||||
}
|
||||
|
||||
// ChannelModelBinding 渠道↔模型绑定(多对多,PLANNING §6.4)
|
||||
@@ -192,26 +195,26 @@ type UsageLog struct {
|
||||
KeyID uint64 `json:"key_id"`
|
||||
// 与 key_id/trace_id 同理:没有任何查询按 channel_id 过滤 usage_logs
|
||||
// (渠道统计走 created_at 时间窗后在内存里分组),复合索引纯属写放大。
|
||||
ChannelID uint64 `json:"channel_id"`
|
||||
ModelID uint64 `json:"model_id"`
|
||||
ModelName string `gorm:"size:128" json:"model_name"`
|
||||
Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"` // 思考 token(上游已计入 output,展示口径不重复计费)
|
||||
CacheWrite1hTokens int64 `json:"cache_write_1h_tokens"` // Anthropic 1h 缓存写 token(计价 2× 输入)
|
||||
ServedModel string `gorm:"size:128" json:"served_model"` // 上游实际应答的模型名(映射/聚合商场景与请求名不同)
|
||||
InputPrice float64 `gorm:"type:numeric(20,8)" json:"input_price"` // 快照
|
||||
OutputPrice float64 `gorm:"type:numeric(20,8)" json:"output_price"` // 快照
|
||||
CacheReadPrice float64 `gorm:"type:numeric(20,8)" json:"cache_read_price"` // 快照
|
||||
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
|
||||
LatencyMS int `json:"latency_ms"`
|
||||
Status string `gorm:"size:16;not null" json:"status"`
|
||||
ErrorCode *string `json:"error_code,omitempty"`
|
||||
RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换)
|
||||
RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件)
|
||||
ChannelID uint64 `json:"channel_id"`
|
||||
ModelID uint64 `json:"model_id"`
|
||||
ModelName string `gorm:"size:128" json:"model_name"`
|
||||
Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"` // 思考 token(上游已计入 output,展示口径不重复计费)
|
||||
CacheWrite1hTokens int64 `json:"cache_write_1h_tokens"` // Anthropic 1h 缓存写 token(计价 2× 输入)
|
||||
ServedModel string `gorm:"size:128" json:"served_model"` // 上游实际应答的模型名(映射/聚合商场景与请求名不同)
|
||||
InputPrice decimal.Decimal `gorm:"type:numeric(20,8)" json:"input_price"` // 快照
|
||||
OutputPrice decimal.Decimal `gorm:"type:numeric(20,8)" json:"output_price"` // 快照
|
||||
CacheReadPrice decimal.Decimal `gorm:"type:numeric(20,8)" json:"cache_read_price"` // 快照
|
||||
Cost decimal.Decimal `gorm:"type:numeric(20,8)" json:"cost"`
|
||||
LatencyMS int `json:"latency_ms"`
|
||||
Status string `gorm:"size:16;not null" json:"status"`
|
||||
ErrorCode *string `json:"error_code,omitempty"`
|
||||
RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换)
|
||||
RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件)
|
||||
// CreatedAt 参与 (user, created) 复合索引(用户侧时间窗过滤),单列索引服务
|
||||
// 全表时间窗扫描(渠道统计、运营总览)。
|
||||
CreatedAt time.Time `gorm:"index;index:idx_user_created,priority:2" json:"created_at"`
|
||||
@@ -219,42 +222,61 @@ type UsageLog struct {
|
||||
|
||||
// UsageDaily 日粒度预聚合(PLANNING §6.6)
|
||||
type UsageDaily struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"`
|
||||
ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"`
|
||||
Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"` // YYYY-MM-DD (UTC)
|
||||
Requests int64 `json:"requests"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"`
|
||||
ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"`
|
||||
Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"` // YYYY-MM-DD (UTC)
|
||||
Requests int64 `json:"requests"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
Cost decimal.Decimal `gorm:"type:numeric(20,8)" json:"cost"`
|
||||
}
|
||||
|
||||
// UsageChannelHourly 渠道×小时预聚合。长窗口的渠道统计(6h/24h/7d)不再扫 usage_logs
|
||||
// 内存分组(窗口大时 5 万行封顶且只反映最近一段),改从桶里读;短窗口(5m/1h)
|
||||
// 仍走明细以保留精确分位延迟。只含计费行(success/canceled 且 cost>0)与错误行。
|
||||
type UsageChannelHourly struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ChannelID uint64 `gorm:"index:idx_ch_hour,unique" json:"channel_id"`
|
||||
Hour string `gorm:"size:13;index:idx_ch_hour,unique" json:"hour"` // "2006-01-02T15" (UTC)
|
||||
Requests int64 `json:"requests"`
|
||||
Success int64 `json:"success"`
|
||||
Canceled int64 `json:"canceled"`
|
||||
Errors int64 `json:"errors"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
Cost decimal.Decimal `gorm:"type:numeric(20,8)" json:"cost"`
|
||||
LatencySum int64 `json:"latency_sum"` // 仅 success 行累计,avg = sum/success
|
||||
LatencyMax int `json:"latency_max"`
|
||||
}
|
||||
|
||||
// RechargeOrder 充值订单(PLANNING §6.7,预留:首版不做充值)
|
||||
type RechargeOrder struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||
Amount float64 `gorm:"type:numeric(20,8);not null" json:"amount"`
|
||||
Status string `gorm:"size:16;not null;default:pending" json:"status"`
|
||||
Method string `gorm:"size:16;not null;default:manual" json:"method"`
|
||||
TransactionID string `gorm:"size:128" json:"transaction_id,omitempty"`
|
||||
ReviewedBy *uint64 `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
Remark string `gorm:"size:512" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||
Amount decimal.Decimal `gorm:"type:numeric(20,8);not null" json:"amount"`
|
||||
Status string `gorm:"size:16;not null;default:pending" json:"status"`
|
||||
Method string `gorm:"size:16;not null;default:manual" json:"method"`
|
||||
TransactionID string `gorm:"size:128" json:"transaction_id,omitempty"`
|
||||
ReviewedBy *uint64 `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
Remark string `gorm:"size:512" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BalanceLog 余额流水(PLANNING §6.8,幂等:ref_id + type 唯一)
|
||||
type BalanceLog struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint64 `gorm:"index:idx_balance_user;not null" json:"user_id"`
|
||||
Change float64 `gorm:"type:numeric(20,8);not null" json:"change"`
|
||||
BalanceAfter float64 `gorm:"type:numeric(20,8);not null" json:"balance_after"`
|
||||
Type string `gorm:"size:16;not null" json:"type"`
|
||||
RefID string `gorm:"size:128;index:idx_balance_ref,unique" json:"ref_id"`
|
||||
Remark string `gorm:"size:512" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint64 `gorm:"index:idx_balance_user;not null" json:"user_id"`
|
||||
Change decimal.Decimal `gorm:"type:numeric(20,8);not null" json:"change"`
|
||||
BalanceAfter decimal.Decimal `gorm:"type:numeric(20,8);not null" json:"balance_after"`
|
||||
Type string `gorm:"size:16;not null" json:"type"`
|
||||
RefID string `gorm:"size:128;index:idx_balance_ref,unique" json:"ref_id"`
|
||||
Remark string `gorm:"size:512" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Passkey WebAuthn 凭据(passkey 绑定/登录)
|
||||
@@ -283,6 +305,7 @@ func AllModels() []any {
|
||||
&ChannelModelBinding{},
|
||||
&UsageLog{},
|
||||
&UsageDaily{},
|
||||
&UsageChannelHourly{},
|
||||
&RechargeOrder{},
|
||||
&BalanceLog{},
|
||||
&Passkey{},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// 保留期只删过期明细,余额流水一条不动;未开启(0)时什么都不做。
|
||||
@@ -18,13 +19,13 @@ func TestRetentionPurgesOldLogsOnly(t *testing.T) {
|
||||
fresh := time.Now().UTC()
|
||||
for i := 0; i < 5; i++ {
|
||||
db.Create(&store.UsageLog{TraceID: fmt.Sprintf("old-%d", i), UserID: u, ModelName: "m1",
|
||||
Cost: 0.01, Status: store.UsageStatusSuccess, CreatedAt: old})
|
||||
Cost: decimal.NewFromFloat(0.01), Status: store.UsageStatusSuccess, CreatedAt: old})
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
db.Create(&store.UsageLog{TraceID: fmt.Sprintf("new-%d", i), UserID: u, ModelName: "m1",
|
||||
Cost: 0.01, Status: store.UsageStatusSuccess, CreatedAt: fresh})
|
||||
Cost: decimal.NewFromFloat(0.01), Status: store.UsageStatusSuccess, CreatedAt: fresh})
|
||||
}
|
||||
db.Create(&store.BalanceLog{UserID: u, Change: -0.05, BalanceAfter: 99.95,
|
||||
db.Create(&store.BalanceLog{UserID: u, Change: decimal.NewFromFloat(-0.05), BalanceAfter: decimal.NewFromFloat(99.95),
|
||||
Type: store.BalanceTypeUsage, RefID: "usage:old-0"})
|
||||
|
||||
n := purgeOlderThan(context.Background(), db, 30)
|
||||
@@ -47,7 +48,7 @@ func TestRetentionDisabled(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
u := seedUser(t, db, 100)
|
||||
db.Create(&store.UsageLog{TraceID: "x", UserID: u, ModelName: "m1",
|
||||
Cost: 0.01, Status: store.UsageStatusSuccess,
|
||||
Cost: decimal.NewFromFloat(0.01), Status: store.UsageStatusSuccess,
|
||||
CreatedAt: time.Now().UTC().AddDate(0, 0, -400)})
|
||||
|
||||
stop := StartRetention(context.Background(), db, 0)
|
||||
@@ -67,7 +68,7 @@ func TestRetentionBatches(t *testing.T) {
|
||||
logs := make([]store.UsageLog, 0, retentionBatch+50)
|
||||
for i := 0; i < retentionBatch+50; i++ {
|
||||
logs = append(logs, store.UsageLog{TraceID: fmt.Sprintf("b-%d", i), UserID: u,
|
||||
ModelName: "m1", Cost: 0.001, Status: store.UsageStatusSuccess, CreatedAt: old})
|
||||
ModelName: "m1", Cost: decimal.NewFromFloat(0.001), Status: store.UsageStatusSuccess, CreatedAt: old})
|
||||
}
|
||||
if err := db.CreateInBatches(logs, 500).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user