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" "github.com/shopspring/decimal" ) // 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 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)"). 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 decimal.Decimal } 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 — 按渠道聚合近一段的 // 请求量、成功率、错误码分布与延迟分位。定位"哪条渠道在掉"不该靠翻日志。 // // 短窗口(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] if !ok { win, d = "1h", statWindows["1h"] } since := time.Now().UTC().Add(-d) agg := map[uint64]*chanAgg{} if d > time.Hour { h.aggChannelStatsHourly(since, agg) } else { h.aggChannelStatsRaw(since, agg) } 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}) } // 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, "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 decimal.Decimal Tokens int64 ErrorCode *string CreatedAt time.Time } type chanAgg struct { requests, success, err, canceled int64 cost decimal.Decimal 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, }) }