feat: 普通用户用量统计 + 管理后台用量明细
后端 - /api/usage/stats:当前用户按日聚合统计(请求数/输入/输出/缓存 tokens/费用) - /api/usage/logs:当前用户用量明细分页 - /api/admin/usage/logs:全量明细(分页 + 协议/状态/模型/用户筛选 + 用户名关联) - /api/admin/usage/summary:全量汇总(按用户分组) - dao:UsageFilter 支持筛选分页;DailyUsageDAO.ListAll - 路由加固:新增 middleware.AdminOnly,既有 /api/admin/* 全部迁移到 带管理员角色校验的分组(此前登录即可访问,属安全隐患) 前端 - 普通用户「用量统计」页:统计卡片 + 纯 CSS 每日请求量条形图 + 明细表格分页 - 管理后台「用量明细」页:汇总卡片 + 协议/状态/模型/用户筛选 + 明细表格 + 原始请求/响应查看弹窗(记录开关开启时) - stores/usage.ts 与类型定义;控制台/管理菜单挂载
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user