Files
opencatd-open/backend/internal/api/usage.go
T
Sakurasan 0628d5050f feat: 用量统计页改版,月度汇总支持多指标图表
- 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序)
- 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」)
- 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换
- 年份切换、悬停明细 tooltip、请求明细保留
2026-09-02 02:36:28 +08:00

392 lines
11 KiB
Go

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
}