diff --git a/backend/internal/api/usage.go b/backend/internal/api/usage.go new file mode 100644 index 0000000..e9e559f --- /dev/null +++ b/backend/internal/api/usage.go @@ -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 +} diff --git a/backend/internal/dao/usage.go b/backend/internal/dao/usage.go index 0396d72..d4a314c 100644 --- a/backend/internal/dao/usage.go +++ b/backend/internal/dao/usage.go @@ -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 +} diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 6c8ea82..34d3e33 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -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() } } diff --git a/backend/router/setRouter.go b/backend/router/setRouter.go index 8b71fe1..5eca2b8 100644 --- a/backend/router/setRouter.go +++ b/backend/router/setRouter.go @@ -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 diff --git a/frontend/src/stores/usage.ts b/frontend/src/stores/usage.ts new file mode 100644 index 0000000..a7d3b65 --- /dev/null +++ b/frontend/src/stores/usage.ts @@ -0,0 +1,95 @@ +// src/stores/usage.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import request from '@/api/client' +import type { UsageStatsData, UsageLogItem, AdminUsageSummary } from '@/types' + +export const useUsageStore = defineStore('usage', () => { + const loading = ref(false) + const error = ref(null) + + // 普通用户:每日统计 + const stats = ref(null) + + // 普通用户:自身明细 + const myLogs = ref([]) + const myLogsTotal = ref(0) + + // 管理后台:全量明细 + const adminLogs = ref([]) + const adminLogsTotal = ref(0) + const adminSummary = ref(null) + + async function fetchStats(days = 30) { + loading.value = true + error.value = null + try { + const res = await request.get('/usage/stats', { params: { days } }) + stats.value = res.data?.data ?? null + } catch (err: any) { + error.value = err.response?.data?.error || '获取用量统计失败' + throw err + } finally { + loading.value = false + } + } + + async function fetchMyLogs(pageSize = 20, page = 1) { + loading.value = true + error.value = null + try { + const res = await request.get('/usage/logs', { params: { pageSize, page } }) + myLogs.value = res.data?.data ?? [] + myLogsTotal.value = res.data?.total ?? 0 + } catch (err: any) { + error.value = err.response?.data?.error || '获取用量明细失败' + throw err + } finally { + loading.value = false + } + } + + async function fetchAdminLogs(params: Record = {}) { + loading.value = true + error.value = null + try { + const res = await request.get('/admin/usage/logs', { params }) + adminLogs.value = res.data?.data ?? [] + adminLogsTotal.value = res.data?.total ?? 0 + } catch (err: any) { + error.value = err.response?.data?.error || '获取用量明细失败' + throw err + } finally { + loading.value = false + } + } + + async function fetchAdminSummary(params: Record = {}) { + loading.value = true + error.value = null + try { + const res = await request.get('/admin/usage/summary', { params }) + adminSummary.value = res.data?.data ?? null + } catch (err: any) { + error.value = err.response?.data?.error || '获取用量汇总失败' + throw err + } finally { + loading.value = false + } + } + + return { + loading, + error, + stats, + myLogs, + myLogsTotal, + adminLogs, + adminLogsTotal, + adminSummary, + fetchStats, + fetchMyLogs, + fetchAdminLogs, + fetchAdminSummary, + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b2476a2..955d60e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -171,3 +171,58 @@ export interface ModelSummary { missing: { channel: string; model_id: number; upstream_model: string }[] denied_count: number } + +// ---- 用量统计 ---- + +export interface UsageDaily { + id?: number + user_id?: number + model_id?: number + date: string + requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cost: number +} + +export interface UsageTotals { + requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cost: number +} + +export interface UsageStatsData { + dates: string[] + daily: Record + totals: UsageTotals +} + +export interface UsageLogItem { + id: number + request_id?: string + user_id: number + channel_id: number + model_id: number + model_name: string + protocol: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_creation_tokens: number + cost: number + latency_ms: number + status: string + error_code?: string | null + created_at: string + username?: string + raw_request?: string + raw_response?: string +} + +export interface AdminUsageSummary { + totals: UsageTotals + per_user: Record +} diff --git a/frontend/src/utils/router_menu.ts b/frontend/src/utils/router_menu.ts index 104c0da..d972991 100644 --- a/frontend/src/utils/router_menu.ts +++ b/frontend/src/utils/router_menu.ts @@ -8,6 +8,7 @@ import { GlobeIcon, BoxesIcon, SlidersHorizontalIcon, + ChartColumnBig, } from '@lucide/vue' export type MenuLink = { label: string; to: string; icon?: Component } @@ -40,6 +41,7 @@ export const routes: RouteRecordRaw[] = [ redirect: '/dashboard/overview', children: [ { path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } }, + { path: 'usage', name: 'UsageStats', component: () => import('@/views/dashboard/UsageStats.vue'), meta: { title: '用量统计' } }, { path: 'apikeys', name: 'ApiKeys', component: () => import('@/views/dashboard/ApiKeys.vue'), meta: { title: 'API Keys' } }, { path: 'manager', @@ -52,6 +54,7 @@ export const routes: RouteRecordRaw[] = [ { path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } }, { path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/ChannelsView.vue'), meta: { title: '渠道管理' } }, { path: 'models', name: 'Models', component: () => import('@/views/dashboard/Models.vue'), meta: { title: '模型定价' } }, + { path: 'usage-logs', name: 'UsageLogs', component: () => import('@/views/dashboard/UsageLogs.vue'), meta: { title: '用量明细' } }, { path: 'config', name: 'SystemConfig', component: () => import('@/views/dashboard/SystemConfig.vue'), meta: { title: '系统配置' } }, ], }, @@ -71,6 +74,7 @@ export const routes: RouteRecordRaw[] = [ // 控制台菜单(所有登录用户) export const consoleMenu: MenuLink[] = [ { label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon }, + { label: '用量统计', to: '/dashboard/usage', icon: ChartColumnBig }, { label: 'API Keys', to: '/dashboard/apikeys', icon: KeyRoundIcon }, { label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon }, ] @@ -80,5 +84,6 @@ export const adminMenu: MenuLink[] = [ { label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon }, { label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon }, { label: '模型定价', to: '/dashboard/manager/models', icon: BoxesIcon }, + { label: '用量明细', to: '/dashboard/manager/usage-logs', icon: ChartColumnBig }, { label: '系统配置', to: '/dashboard/manager/config', icon: SlidersHorizontalIcon }, ] diff --git a/frontend/src/views/dashboard/UsageLogs.vue b/frontend/src/views/dashboard/UsageLogs.vue new file mode 100644 index 0000000..977f0d0 --- /dev/null +++ b/frontend/src/views/dashboard/UsageLogs.vue @@ -0,0 +1,240 @@ + + + diff --git a/frontend/src/views/dashboard/UsageStats.vue b/frontend/src/views/dashboard/UsageStats.vue new file mode 100644 index 0000000..5be4840 --- /dev/null +++ b/frontend/src/views/dashboard/UsageStats.vue @@ -0,0 +1,180 @@ + + +