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:
Sakurasan
2026-09-01 02:55:55 +08:00
parent f9e9a1572f
commit a259e5eb4d
9 changed files with 955 additions and 23 deletions
+265
View File
@@ -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
}
+61
View File
@@ -55,6 +55,50 @@ func (d *UsageDAO) CountByUserID(ctx context.Context, userID uint64) (int64, err
return count, err
}
// UsageFilter 用量明细筛选条件(管理后台)。
type UsageFilter struct {
UserID *uint64 // 指定用户(nil=全部)
Protocol string // 协议 chat/messages/responses(空=全部)
Status string // success/error/canceled(空=全部)
ModelName string // 模型名模糊(空=全部)
Limit int
Offset int
}
// ListAll 管理后台全量用量明细(分页 + 筛选),并带用户名。
func (d *UsageDAO) ListAll(ctx context.Context, f UsageFilter) ([]store.UsageLog, error) {
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
q = applyUsageFilter(q, f)
var logs []store.UsageLog
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&logs).Error
return logs, err
}
// CountAll 统计符合筛选条件的明细总数。
func (d *UsageDAO) CountAll(ctx context.Context, f UsageFilter) (int64, error) {
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
q = applyUsageFilter(q, f)
var count int64
err := q.Count(&count).Error
return count, err
}
func applyUsageFilter(q *gorm.DB, f UsageFilter) *gorm.DB {
if f.UserID != nil {
q = q.Where("user_id = ?", *f.UserID)
}
if f.Protocol != "" {
q = q.Where("protocol = ?", f.Protocol)
}
if f.Status != "" {
q = q.Where("status = ?", f.Status)
}
if f.ModelName != "" {
q = q.Where("model_name LIKE ?", "%"+f.ModelName+"%")
}
return q
}
// UsageDaily DAO
func (d *DailyUsageDAO) Create(ctx context.Context, log *store.UsageDaily) error {
return d.db.WithContext(ctx).Create(log).Error
@@ -106,3 +150,20 @@ func (d *DailyUsageDAO) ListByDateRange(ctx context.Context, userID uint64, star
Find(&logs).Error
return logs, err
}
// ListAll 管理后台:全部用户的日聚合(可选按用户/日期范围筛选),按日期倒序。
func (d *DailyUsageDAO) ListAll(ctx context.Context, userID *uint64, start, end string) ([]store.UsageDaily, error) {
q := d.db.WithContext(ctx).Model(&store.UsageDaily{})
if userID != nil {
q = q.Where("user_id = ?", *userID)
}
if start != "" {
q = q.Where("date >= ?", start)
}
if end != "" {
q = q.Where("date <= ?", end)
}
var logs []store.UsageDaily
err := q.Order("date DESC, user_id ASC").Find(&logs).Error
return logs, err
}
+19
View File
@@ -48,6 +48,25 @@ func Auth(db *gorm.DB) gin.HandlerFunc {
func CheckRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, _ := c.Get("user_role")
if roleStr, ok := userRole.(string); !ok || roleStr != role {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
return
}
c.Next()
}
}
// AdminOnly 管理后台中间件:要求 user_role 为 admin。
// 由 middleware.Auth 先行设置 user_role;缺失时拒绝。
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get("user_role")
roleStr, _ := role.(string)
if roleStr != store.RoleAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return
}
c.Next()
}
}
+35 -23
View File
@@ -113,34 +113,46 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
apiGroup.PUT("/models/:id", apiHandler.UpdateModel)
apiGroup.DELETE("/models/:id", apiHandler.DeleteModel)
// 用户自身用量统计
apiGroup.GET("/usage/stats", apiHandler.MyUsageStats)
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
}
// Admin API (requires admin role)
adminGroup := r.Group("/api/admin", middleware.Auth(db), middleware.AdminOnly())
{
// Admin channel management (enhanced)
apiGroup.GET("/admin/channels", apiHandler.AdminChannels)
apiGroup.POST("/admin/channels", apiHandler.AdminCreateChannel)
apiGroup.PUT("/admin/channels/:id", apiHandler.AdminUpdateChannel)
apiGroup.DELETE("/admin/channels/:id", apiHandler.AdminDeleteChannel)
apiGroup.POST("/admin/channels/:id/test", apiHandler.AdminTestChannel)
apiGroup.GET("/admin/channels/:id/models/remote", apiHandler.AdminChannelRemoteModels)
apiGroup.GET("/admin/channels/:id/models", apiHandler.AdminChannelModels)
apiGroup.POST("/admin/channels/:id/models", apiHandler.AdminChannelAddModel)
apiGroup.PATCH("/admin/channels/:id/models/:bid", apiHandler.AdminChannelUpdateModel)
apiGroup.DELETE("/admin/channels/:id/models/:bid", apiHandler.AdminChannelDeleteModel)
adminGroup.GET("/channels", apiHandler.AdminChannels)
adminGroup.POST("/channels", apiHandler.AdminCreateChannel)
adminGroup.PUT("/channels/:id", apiHandler.AdminUpdateChannel)
adminGroup.DELETE("/channels/:id", apiHandler.AdminDeleteChannel)
adminGroup.POST("/channels/:id/test", apiHandler.AdminTestChannel)
adminGroup.GET("/channels/:id/models/remote", apiHandler.AdminChannelRemoteModels)
adminGroup.GET("/channels/:id/models", apiHandler.AdminChannelModels)
adminGroup.POST("/channels/:id/models", apiHandler.AdminChannelAddModel)
adminGroup.PATCH("/channels/:id/models/:bid", apiHandler.AdminChannelUpdateModel)
adminGroup.DELETE("/channels/:id/models/:bid", apiHandler.AdminChannelDeleteModel)
// Admin model management (enhanced)
apiGroup.GET("/admin/models", apiHandler.AdminModels)
apiGroup.DELETE("/admin/models/unused", apiHandler.AdminDeleteUnusedModels)
apiGroup.POST("/admin/models", apiHandler.AdminCreateModel)
apiGroup.PUT("/admin/models/:id", apiHandler.AdminUpdateModel)
apiGroup.DELETE("/admin/models/:id", apiHandler.AdminDeleteModel)
apiGroup.POST("/admin/models/:id/bindings", apiHandler.AdminCreateModelBinding)
apiGroup.DELETE("/admin/models/:id/bindings/:bid", apiHandler.AdminDeleteModelBinding)
adminGroup.GET("/models", apiHandler.AdminModels)
adminGroup.DELETE("/models/unused", apiHandler.AdminDeleteUnusedModels)
adminGroup.POST("/models", apiHandler.AdminCreateModel)
adminGroup.PUT("/models/:id", apiHandler.AdminUpdateModel)
adminGroup.DELETE("/models/:id", apiHandler.AdminDeleteModel)
adminGroup.POST("/models/:id/bindings", apiHandler.AdminCreateModelBinding)
adminGroup.DELETE("/models/:id/bindings/:bid", apiHandler.AdminDeleteModelBinding)
// Admin system config
apiGroup.GET("/admin/config", apiHandler.AdminGetConfig)
apiGroup.PUT("/admin/config", apiHandler.AdminUpdateConfig)
apiGroup.GET("/admin/config/registration", apiHandler.AdminGetRegistration)
apiGroup.PUT("/admin/config/registration", apiHandler.AdminUpdateRegistration)
apiGroup.GET("/admin/config/password-login", apiHandler.AdminGetPasswordLogin)
apiGroup.PUT("/admin/config/password-login", apiHandler.AdminUpdatePasswordLogin)
adminGroup.GET("/config", apiHandler.AdminGetConfig)
adminGroup.PUT("/config", apiHandler.AdminUpdateConfig)
adminGroup.GET("/config/registration", apiHandler.AdminGetRegistration)
adminGroup.PUT("/config/registration", apiHandler.AdminUpdateRegistration)
adminGroup.GET("/config/password-login", apiHandler.AdminGetPasswordLogin)
adminGroup.PUT("/config/password-login", apiHandler.AdminUpdatePasswordLogin)
// Admin usage
adminGroup.GET("/usage/logs", apiHandler.AdminUsageLogs)
adminGroup.GET("/usage/summary", apiHandler.AdminUsageSummary)
}
// LLM proxy routes