Files
openteam/server/internal/api/admin_stats.go
T
Sakurasan 8c12207478 perf: 目录缓存与流式正文快路径,请求热路径开销减半
按性能分析逐项落地,实测对比(16 并发、mock 上游零延迟、SQLite):

- 每请求 SQL 10 → 2:模型/渠道目录(模型行、绑定、渠道行)进程内缓存 10s,
  运行期状态(冷却、健康)仍每次实时判定;管理端写操作经中间件、
  健康状态变化经回调立即失效。新增 TestProxyRequestSQLBudget 钉住预算。
- 端到端 p50 1.3–1.6ms → 0.66–0.70ms,RPS 3.5–8.9k → 16–18k。
- 流式正文累积:字符串 += 改 strings.Builder(原为 O(n²)),
  逐行 JSON 提取改字节快路径,并加廉价的子串预过滤、拿到输出 usage 后停累积。
  500 块:949µs/7.9MB 分配 → 83µs/136KB。
- 请求体定点改写:模型映射从整包 unmarshal/marshal 改为顶层字段替换
  (100KB:585µs → 29µs),stream_options 注入同理;requestText 改为
  只在「上游没报用量」时按需提取,亲和键改用原始体前缀。
- 渠道满载背压:新增 Acquire 有界排队(OT_PROXY_CHANNEL_ACQUIRE_WAIT,
  默认 150ms);排队仍满时返回 503 + Retry-After 而不是 502。
  实测渠道上限 4、16 并发:无排队 81 次 503;排队 150ms 后 400/400 成功。
- 记账按批聚合:同批同用户一次扣款、流水批量插入、日表按 (user,model,日期)
  聚合,替代每行 5 条语句;幂等仍靠 ref_id 唯一索引兜底。
- gorm 开启 PrepareStmt(SQL 解析此前占 CPU 15%);tiktoken 编码器按模型复用。
- 管理端用量列表不再返回原始请求/响应体(可能几十 MB),
  改为 has_raw 标记 + GET /admin/usage/:id 按需拉取,前端弹窗打开时取。

验证:go vet、go test ./...、go test -race(proxy/channel/usage/tokenizer)、
vue-tsc、eslint、vite build 全绿;本地起 mock 上游实测列表/详情接口与
管理页弹窗按需加载。
2026-10-30 14:14:21 +08:00

306 lines
9.7 KiB
Go

package api
import (
"math"
"net/http"
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
// AdminStatsOverview GET /api/v1/admin/stats/overview — 运营总览。
func (h *Handler) AdminStatsOverview(c *gin.Context) {
now := time.Now().UTC()
today := now.Format("2006-01-02")
month := now.Format("2006-01")
var totalUsers int64
h.a.DB.Model(&store.User{}).Count(&totalUsers)
var totalKeys int64
h.a.DB.Model(&store.APIKey{}).Count(&totalKeys)
var totalChannels int64
h.a.DB.Model(&store.Channel{}).Count(&totalChannels)
var totalModels int64
h.a.DB.Model(&store.Model{}).Count(&totalModels)
// 全局今日/本月汇总(跨用户)
var todayReq, monthReq int64
var todayCost, monthCost float64
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)").
Row().Scan(&todayReq, &todayCost, &todayTokens)
h.a.DB.Model(&store.UsageDaily{}).Where("date LIKE ?", month+"%").
Select("COALESCE(SUM(requests),0), COALESCE(SUM(cost),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0)").
Row().Scan(&monthReq, &monthCost, &monthTokens)
// 近 14 天趋势(日粒度)
var days []struct {
Date string
Req int64
Cost float64
}
h.a.DB.Model(&store.UsageDaily{}).
Select("date, SUM(requests) req, SUM(cost) cost").
Where("date >= ?", now.AddDate(0, 0, -13).Format("2006-01-02")).
Group("date").Order("date").Scan(&days)
trend := make([]gin.H, 0, len(days))
for _, d := range days {
trend = append(trend, gin.H{"date": d.Date, "requests": d.Req, "cost": d.Cost})
}
resp.OK(c, gin.H{
"total_users": totalUsers, "total_keys": totalKeys,
"total_channels": totalChannels, "total_models": totalModels,
"today": gin.H{"requests": todayReq, "cost": todayCost, "tokens": todayTokens},
"month": gin.H{"requests": monthReq, "cost": monthCost, "tokens": monthTokens},
"trend_14d": trend,
})
}
// AdminChannelStats GET /api/v1/admin/channels/stats?window=1h — 按渠道聚合近一段的
// 请求量、成功率、错误码分布与延迟分位。定位"哪条渠道在掉"不该靠翻日志。
func (h *Handler) AdminChannelStats(c *gin.Context) {
win := c.DefaultQuery("window", "1h")
d, ok := statWindows[win]
if !ok {
win, d = "1h", statWindows["1h"]
}
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
}
}
var chans []store.Channel
h.a.DB.Find(&chans)
names := make(map[uint64]string, len(chans))
for _, ch := range chans {
names[ch.ID] = ch.Name
}
out := make([]gin.H, 0, len(agg))
for id, a := range agg {
out = append(out, gin.H{
"channel_id": id, "channel_name": names[id],
"requests": a.requests, "success": a.success,
"error": a.err, "canceled": a.canceled,
"err_rate": rate(a.err, a.requests), // canceled 是客户端主动断开,不算渠道的错
"p50_ms": percentile(a.lat, 0.50),
"p95_ms": percentile(a.lat, 0.95),
"max_ms": percentile(a.lat, 1.0),
"tokens": a.tokens,
"cost": a.cost,
"top_errors": topErrors(a.errs, 3),
"last_at": a.lastAt,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["requests"].(int64) > out[j]["requests"].(int64)
})
resp.OK(c, gin.H{"window": win, "since": since, "items": out})
}
// statWindows 支持的统计窗口。
var statWindows = map[string]time.Duration{
"5m": 5 * time.Minute,
"1h": time.Hour,
"6h": 6 * time.Hour,
"24h": 24 * time.Hour,
"7d": 7 * 24 * time.Hour,
}
type channelStatRow struct {
ChannelID uint64
Status string
LatencyMS int
Cost float64
Tokens int64
ErrorCode *string
CreatedAt time.Time
}
type chanAgg struct {
requests, success, err, canceled int64
cost float64
tokens int64
lat []int
errs map[string]int
lastAt time.Time
}
func rate(part, total int64) float64 {
if total <= 0 {
return 0
}
return float64(part) / float64(total)
}
// percentile 最近秩法取分位(q=1 为最大值);空样本返回 0。
func percentile(xs []int, q float64) int {
if len(xs) == 0 {
return 0
}
sort.Ints(xs)
idx := int(math.Ceil(q*float64(len(xs)))) - 1
if idx < 0 {
idx = 0
}
if idx >= len(xs) {
idx = len(xs) - 1
}
return xs[idx]
}
func topErrors(m map[string]int, n int) []gin.H {
list := make([]gin.H, 0, len(m))
for code, cnt := range m {
list = append(list, gin.H{"code": code, "count": cnt})
}
sort.Slice(list, func(i, j int) bool { return list[i]["count"].(int) > list[j]["count"].(int) })
if len(list) > n {
list = list[:n]
}
return list
}
// AdminUsage GET /api/v1/admin/usage — 全局用量日志(分页 + 过滤)。
func (h *Handler) AdminUsage(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.a.DB.Model(&store.UsageLog{})
if from := c.Query("from"); from != "" {
q = q.Where("created_at >= ?", from+" 00:00:00")
}
if to := c.Query("to"); to != "" {
q = q.Where("created_at <= ?", to+" 23:59:59")
}
if model := c.Query("model"); model != "" {
q = q.Where("model_name = ?", model)
}
if user := c.Query("user_id"); user != "" {
q = q.Where("user_id = ?", user)
}
var total int64
q.Count(&total)
var logs []store.UsageLog
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs)
// 批量预取用户名与渠道名:每行两次查询在 100 行分页下是 200 次额外查询
userNames := map[uint64]string{}
chNames := map[uint64]string{}
userIDs := make([]uint64, 0, len(logs))
chIDs := make([]uint64, 0, len(logs))
for _, l := range logs {
userIDs = append(userIDs, l.UserID)
if l.ChannelID > 0 {
chIDs = append(chIDs, l.ChannelID)
}
}
if len(userIDs) > 0 {
var us []store.User
h.a.DB.Select("id, username").Where("id IN ?", userIDs).Find(&us)
for _, u := range us {
userNames[u.ID] = u.Username
}
}
if len(chIDs) > 0 {
var cs []store.Channel
h.a.DB.Select("id, name").Where("id IN ?", chIDs).Find(&cs)
for _, ch := range cs {
chNames[ch.ID] = ch.Name
}
}
out := make([]gin.H, 0, len(logs))
for _, l := range logs {
out = append(out, gin.H{
"id": l.ID, "user": userNames[l.UserID], "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol,
"channel_id": l.ChannelID, "channel": chNames[l.ChannelID],
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
"cache_read_tokens": l.CacheReadTokens, "cache_creation_tokens": l.CacheCreationTokens,
"reasoning_tokens": l.ReasoningTokens, "cache_write_1h_tokens": l.CacheWrite1hTokens,
"served_model": l.ServedModel, "cost": l.Cost,
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
// 原始体(可能是整段 SSE,百 KB~MB 级)不进列表:15 行一页就能到几十 MB。
// 只给一个标记,需要时走 /admin/usage/:id 单独取。
"has_raw": l.RawRequest != "" || l.RawResponse != "",
"created_at": l.CreatedAt,
})
}
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
// AdminUsageDetail 单条用量的完整明细(含原始请求/响应体)。
// 列表接口刻意不返回原始体:它们可能是整段 SSE,把列表响应撑到几十 MB 没有意义。
func (h *Handler) AdminUsageDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid usage id")
return
}
var l store.UsageLog
if err := h.a.DB.First(&l, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "usage log not found")
return
}
var u store.User
h.a.DB.Select("id, username").First(&u, l.UserID)
var ch store.Channel
if l.ChannelID > 0 {
h.a.DB.Select("id, name").First(&ch, l.ChannelID)
}
resp.OK(c, gin.H{
"id": l.ID, "user": u.Username, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol,
"channel_id": l.ChannelID, "channel": ch.Name,
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
"cache_read_tokens": l.CacheReadTokens, "cache_creation_tokens": l.CacheCreationTokens,
"reasoning_tokens": l.ReasoningTokens, "cache_write_1h_tokens": l.CacheWrite1hTokens,
"served_model": l.ServedModel, "cost": l.Cost,
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
"raw_request": l.RawRequest, "raw_response": l.RawResponse,
"has_raw": l.RawRequest != "" || l.RawResponse != "",
"created_at": l.CreatedAt,
})
}