package api import ( "fmt" "net/http" "sort" "strconv" "time" "opencatd-open/internal/dao" "opencatd-open/internal/store" "github.com/gin-gonic/gin" ) // --- 普通用户:自身用量统计与明细 --- // MyUsageStats GET /api/usage/stats?days=30 — 当前用户的每日用量聚合。 func (h *Handler) MyUsageStats(c *gin.Context) { userID, _ := c.Get("user_id") uid, _ := userID.(uint64) days := 30 if d := c.Query("days"); d != "" { if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 { days = n } } end := time.Now() start := end.AddDate(0, 0, -days) dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"}) return } // 按日期聚合(每日可能多模型多行) byDate := map[string]*store.UsageDaily{} var dates []string for i := range dailies { d := dailies[i] agg, ok := byDate[d.Date] if !ok { agg = &store.UsageDaily{Date: d.Date} byDate[d.Date] = agg dates = append(dates, d.Date) } agg.Requests += d.Requests agg.InputTokens += d.InputTokens agg.OutputTokens += d.OutputTokens agg.CacheReadTokens += d.CacheReadTokens agg.Cost += d.Cost } // 汇总 var totalRequests, totalInput, totalOutput, totalCache int64 var totalCost float64 for _, d := range byDate { totalRequests += d.Requests totalInput += d.InputTokens totalOutput += d.OutputTokens totalCache += d.CacheReadTokens totalCost += d.Cost } c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "dates": dates, "daily": byDate, "totals": gin.H{ "requests": totalRequests, "input_tokens": totalInput, "output_tokens": totalOutput, "cache_read_tokens": totalCache, "cost": totalCost, }, }, }) } // MyUsageMonthly GET /api/usage/monthly?year=2026 — 当前用户年度按自然月聚合, // 每月含按模型分解(供月度堆叠柱状图使用)。 func (h *Handler) MyUsageMonthly(c *gin.Context) { userID, _ := c.Get("user_id") uid, _ := userID.(uint64) year := time.Now().Year() if y := c.Query("year"); y != "" { if n, err := strconv.Atoi(y); err == nil && n >= 2000 && n <= 2100 { year = n } } start := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local) end := start.AddDate(1, 0, -1) dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"}) return } // 补齐模型名(模型可能已被删除,回退为 模型#id) modelIDs := make([]uint64, 0, len(dailies)) seen := map[uint64]bool{} for _, d := range dailies { if !seen[d.ModelID] { seen[d.ModelID] = true modelIDs = append(modelIDs, d.ModelID) } } modelNames := map[uint64]string{} if len(modelIDs) > 0 { var models []store.Model if err := h.db.Where("id IN ?", modelIDs).Find(&models).Error; err == nil { for _, m := range models { modelNames[m.ID] = m.Name } } } type modelAgg struct { ModelID uint64 `json:"model_id"` ModelName string `json:"model_name"` Requests int64 `json:"requests"` InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` Cost float64 `json:"cost"` } type monthAgg struct { Month string `json:"month"` Requests int64 `json:"requests"` InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` Cost float64 `json:"cost"` Models map[uint64]*modelAgg `json:"-"` } months := make([]*monthAgg, 12) for i := range months { months[i] = &monthAgg{ Month: fmt.Sprintf("%d-%02d", year, i+1), Models: map[uint64]*modelAgg{}, } } for _, d := range dailies { mm, err := strconv.Atoi(d.Date[5:7]) if err != nil || mm < 1 || mm > 12 { continue } m := months[mm-1] m.Requests += d.Requests m.InputTokens += d.InputTokens m.OutputTokens += d.OutputTokens m.CacheReadTokens += d.CacheReadTokens m.Cost += d.Cost ma, ok := m.Models[d.ModelID] if !ok { name := modelNames[d.ModelID] if name == "" { name = fmt.Sprintf("模型#%d", d.ModelID) } ma = &modelAgg{ModelID: d.ModelID, ModelName: name} m.Models[d.ModelID] = ma } ma.Requests += d.Requests ma.InputTokens += d.InputTokens ma.OutputTokens += d.OutputTokens ma.CacheReadTokens += d.CacheReadTokens ma.Cost += d.Cost } out := make([]gin.H, 12) for i, m := range months { modelList := make([]*modelAgg, 0, len(m.Models)) for _, ma := range m.Models { modelList = append(modelList, ma) } // 模型按 token 总量降序,柱状图图例顺序与之一致 sort.Slice(modelList, func(a, b int) bool { ta := modelList[a].InputTokens + modelList[a].OutputTokens + modelList[a].CacheReadTokens tb := modelList[b].InputTokens + modelList[b].OutputTokens + modelList[b].CacheReadTokens return ta > tb }) out[i] = gin.H{ "month": m.Month, "requests": m.Requests, "input_tokens": m.InputTokens, "output_tokens": m.OutputTokens, "cache_read_tokens": m.CacheReadTokens, "cost": m.Cost, "models": modelList, } } c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "year": year, "months": out, }, }) } // MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。 func (h *Handler) MyUsageLogs(c *gin.Context) { userID, _ := c.Get("user_id") uid, _ := userID.(uint64) limit, offset := paginate(c, 20) logs, err := h.usageDAO.ListByUserID(c.Request.Context(), uid, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"}) return } total, err := h.usageDAO.CountByUserID(c.Request.Context(), uid) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"}) return } valLogs := make([]store.UsageLog, len(logs)) for i, l := range logs { valLogs[i] = *l } c.JSON(http.StatusOK, gin.H{"data": usageLogsToResp(valLogs, nil), "total": total}) } // --- 管理后台:全量用量明细 --- // AdminUsageLogs GET /api/admin/usage/logs?page=&pageSize=&protocol=&status=&model=&user_id= func (h *Handler) AdminUsageLogs(c *gin.Context) { f := daoUsageFilter(c) logs, err := h.usageDAO.ListAll(c.Request.Context(), f) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"}) return } total, err := h.usageDAO.CountAll(c.Request.Context(), f) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"}) return } names := h.userNames(logs) c.JSON(http.StatusOK, gin.H{ "data": usageLogsToResp(logs, names), "total": total, }) } // AdminUsageSummary GET /api/admin/usage/summary?start=&end=&user_id= — 全量汇总。 func (h *Handler) AdminUsageSummary(c *gin.Context) { var uidPtr *uint64 if v := c.Query("user_id"); v != "" { if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 { uidPtr = &n } } dailies, err := h.dailyDAO.ListAll(c.Request.Context(), uidPtr, c.Query("start"), c.Query("end")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"}) return } var totalRequests, totalInput, totalOutput, totalCache int64 var totalCost float64 perUser := map[uint64]*gin.H{} for _, d := range dailies { totalRequests += d.Requests totalInput += d.InputTokens totalOutput += d.OutputTokens totalCache += d.CacheReadTokens totalCost += d.Cost u, ok := perUser[d.UserID] if !ok { u = &gin.H{"user_id": d.UserID, "requests": int64(0), "input_tokens": int64(0), "output_tokens": int64(0), "cost": float64(0)} perUser[d.UserID] = u } (*u)["requests"] = (*u)["requests"].(int64) + d.Requests (*u)["input_tokens"] = (*u)["input_tokens"].(int64) + d.InputTokens (*u)["output_tokens"] = (*u)["output_tokens"].(int64) + d.OutputTokens (*u)["cost"] = (*u)["cost"].(float64) + d.Cost } c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "totals": gin.H{ "requests": totalRequests, "input_tokens": totalInput, "output_tokens": totalOutput, "cache_read_tokens": totalCache, "cost": totalCost, }, "per_user": perUser, }, }) } // --- helpers --- // paginate 解析 page/pageSize 查询参数,返回 limit/offset。 func paginate(c *gin.Context, defSize int) (int, int) { limit := defSize offset := 0 if pageSize := c.Query("pageSize"); pageSize != "" { if n, err := strconv.Atoi(pageSize); err == nil && n > 0 && n <= 100 { limit = n } } if page := c.Query("page"); page != "" { if p, err := strconv.Atoi(page); err == nil && p > 0 { offset = (p - 1) * limit } } return limit, offset } func daoUsageFilter(c *gin.Context) dao.UsageFilter { limit, offset := paginate(c, 20) f := dao.UsageFilter{Limit: limit, Offset: offset} f.Protocol = c.Query("protocol") f.Status = c.Query("status") f.ModelName = c.Query("model") if v := c.Query("user_id"); v != "" { if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 { f.UserID = &n } } return f } func usageLogsToResp(logs []store.UsageLog, names map[uint64]string) []gin.H { out := make([]gin.H, 0, len(logs)) for _, l := range logs { row := gin.H{ "id": l.ID, "request_id": l.RequestID, "user_id": l.UserID, "channel_id": l.ChannelID, "model_id": l.ModelID, "model_name": l.ModelName, "protocol": l.Protocol, "input_tokens": l.InputTokens, "output_tokens": l.OutputTokens, "cache_read_tokens": l.CacheReadTokens, "cache_creation_tokens": l.CacheCreationTokens, "cost": l.Cost, "latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode, "created_at": l.CreatedAt, } if names != nil { if u, ok := names[l.UserID]; ok { row["username"] = u } } if l.RawRequest != "" { row["raw_request"] = l.RawRequest } if l.RawResponse != "" { row["raw_response"] = l.RawResponse } out = append(out, row) } return out } // userNames 批量查询 user_id → username 映射。 func (h *Handler) userNames(logs []store.UsageLog) map[uint64]string { ids := map[uint64]bool{} for _, l := range logs { ids[l.UserID] = true } if len(ids) == 0 { return nil } idList := make([]uint64, 0, len(ids)) for id := range ids { idList = append(idList, id) } var users []store.User if err := h.db.Where("id IN ?", idList).Find(&users).Error; err != nil { return nil } out := map[uint64]string{} for _, u := range users { out[u.ID] = u.Username } return out }