diff --git a/backend/api/ai.api b/backend/api/ai.api index 16f16c9..a904502 100644 --- a/backend/api/ai.api +++ b/backend/api/ai.api @@ -250,4 +250,94 @@ type ( TotalConsumed float64 `json:"totalConsumed"` FrozenAmount float64 `json:"frozenAmount"` } + + AIQuotaUserInfo { + UserId int64 `json:"userId,string"` + Username string `json:"username"` + Balance float64 `json:"balance"` + TotalRecharged float64 `json:"totalRecharged"` + TotalConsumed float64 `json:"totalConsumed"` + FrozenAmount float64 `json:"frozenAmount"` + } + + AIQuotaListRequest { + Page int64 `form:"page,optional,default=1"` + PageSize int64 `form:"pageSize,optional,default=20"` + } + + AIQuotaListResponse { + List []AIQuotaUserInfo `json:"list"` + Total int64 `json:"total"` + } + + AIQuotaRechargeRequest { + UserId int64 `json:"userId,string"` + Amount float64 `json:"amount"` + Remark string `json:"remark,optional"` + } +) + +// ========== Usage Record Types ========== + +type ( + AIUsageRecordInfo { + Id int64 `json:"id,string"` + UserId int64 `json:"userId,string"` + Username string `json:"username"` + ProviderId int64 `json:"providerId,string"` + ProviderName string `json:"providerName"` + ModelId string `json:"modelId"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + Cost float64 `json:"cost"` + Status string `json:"status"` + LatencyMs int `json:"latencyMs"` + ErrorMessage string `json:"errorMessage"` + CreatedAt string `json:"createdAt"` + } + + AIUsageRecordListRequest { + Page int64 `form:"page,optional,default=1"` + PageSize int64 `form:"pageSize,optional,default=20"` + UserId int64 `form:"userId,optional"` + ModelId string `form:"modelId,optional"` + Status string `form:"status,optional"` + } + + AIUsageRecordListResponse { + List []AIUsageRecordInfo `json:"list"` + Total int64 `json:"total"` + } +) + +// ========== Stats Types ========== + +type ( + AIUsageStatsResponse { + TotalCalls int64 `json:"totalCalls"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` + TotalUsers int64 `json:"totalUsers"` + ModelStats []AIModelStatItem `json:"modelStats"` + DailyStats []AIDailyStatItem `json:"dailyStats"` + } + + AIModelStatItem { + ModelId string `json:"modelId"` + Calls int64 `json:"calls"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalCost float64 `json:"totalCost"` + } + + AIDailyStatItem { + Date string `json:"date"` + Calls int64 `json:"calls"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` + } + + AIUsageStatsRequest { + Days int `form:"days,optional,default=30"` + } ) diff --git a/backend/base.api b/backend/base.api index 4930168..978bb15 100644 --- a/backend/base.api +++ b/backend/base.api @@ -349,6 +349,10 @@ service base-api { @doc "删除API Key" @handler AiApiKeyDelete delete /ai/key/:id (AIApiKeyDeleteRequest) returns (Response) + + @doc "获取我的用量记录" + @handler AiUsageRecordList + get /ai/quota/records (AIUsageRecordListRequest) returns (AIUsageRecordListResponse) } // ========== AI 管理(admin 权限)========== @@ -385,5 +389,17 @@ service base-api { @doc "删除AI模型" @handler AiModelDelete delete /ai/model/:id (AIModelDeleteRequest) returns (Response) + + @doc "获取用户额度列表" + @handler AiQuotaList + get /ai/quotas (AIQuotaListRequest) returns (AIQuotaListResponse) + + @doc "充值用户额度" + @handler AiQuotaRecharge + post /ai/quota/recharge (AIQuotaRechargeRequest) returns (Response) + + @doc "获取AI使用统计" + @handler AiUsageStats + get /ai/stats (AIUsageStatsRequest) returns (AIUsageStatsResponse) } diff --git a/backend/internal/handler/ai/aiquotalisthandler.go b/backend/internal/handler/ai/aiquotalisthandler.go new file mode 100644 index 0000000..c87ac8c --- /dev/null +++ b/backend/internal/handler/ai/aiquotalisthandler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package ai + +import ( + "net/http" + + "github.com/youruser/base/internal/logic/ai" + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 获取用户额度列表 +func AiQuotaListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AIQuotaListRequest + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + + l := ai.NewAiQuotaListLogic(r.Context(), svcCtx) + resp, err := l.AiQuotaList(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} diff --git a/backend/internal/handler/ai/aiquotarechargehandler.go b/backend/internal/handler/ai/aiquotarechargehandler.go new file mode 100644 index 0000000..99bd101 --- /dev/null +++ b/backend/internal/handler/ai/aiquotarechargehandler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package ai + +import ( + "net/http" + + "github.com/youruser/base/internal/logic/ai" + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 充值用户额度 +func AiQuotaRechargeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AIQuotaRechargeRequest + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + + l := ai.NewAiQuotaRechargeLogic(r.Context(), svcCtx) + resp, err := l.AiQuotaRecharge(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} diff --git a/backend/internal/handler/ai/aiusagerecordlisthandler.go b/backend/internal/handler/ai/aiusagerecordlisthandler.go new file mode 100644 index 0000000..e44a171 --- /dev/null +++ b/backend/internal/handler/ai/aiusagerecordlisthandler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package ai + +import ( + "net/http" + + "github.com/youruser/base/internal/logic/ai" + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 获取我的用量记录 +func AiUsageRecordListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AIUsageRecordListRequest + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + + l := ai.NewAiUsageRecordListLogic(r.Context(), svcCtx) + resp, err := l.AiUsageRecordList(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} diff --git a/backend/internal/handler/ai/aiusagestatshandler.go b/backend/internal/handler/ai/aiusagestatshandler.go new file mode 100644 index 0000000..1576002 --- /dev/null +++ b/backend/internal/handler/ai/aiusagestatshandler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package ai + +import ( + "net/http" + + "github.com/youruser/base/internal/logic/ai" + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 获取AI使用统计 +func AiUsageStatsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AIUsageStatsRequest + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + + l := ai.NewAiUsageStatsLogic(r.Context(), svcCtx) + resp, err := l.AiUsageStats(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 4c9c73b..8fb5b7e 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -97,6 +97,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/ai/quota/me", Handler: ai.AiQuotaMeHandler(serverCtx), }, + { + // 获取我的用量记录 + Method: http.MethodGet, + Path: "/ai/quota/records", + Handler: ai.AiUsageRecordListHandler(serverCtx), + }, }..., ), rest.WithPrefix("/api/v1"), @@ -148,6 +154,24 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/ai/providers", Handler: ai.AiProviderListHandler(serverCtx), }, + { + // 充值用户额度 + Method: http.MethodPost, + Path: "/ai/quota/recharge", + Handler: ai.AiQuotaRechargeHandler(serverCtx), + }, + { + // 获取用户额度列表 + Method: http.MethodGet, + Path: "/ai/quotas", + Handler: ai.AiQuotaListHandler(serverCtx), + }, + { + // 获取AI使用统计 + Method: http.MethodGet, + Path: "/ai/stats", + Handler: ai.AiUsageStatsHandler(serverCtx), + }, }..., ), rest.WithPrefix("/api/v1"), diff --git a/backend/internal/logic/ai/aiquotalistlogic.go b/backend/internal/logic/ai/aiquotalistlogic.go new file mode 100644 index 0000000..34f199e --- /dev/null +++ b/backend/internal/logic/ai/aiquotalistlogic.go @@ -0,0 +1,65 @@ +package ai + +import ( + "context" + + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/youruser/base/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AiQuotaListLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAiQuotaListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiQuotaListLogic { + return &AiQuotaListLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AiQuotaListLogic) AiQuotaList(req *types.AIQuotaListRequest) (resp *types.AIQuotaListResponse, err error) { + quotas, total, err := model.AIUserQuotaFindList(l.ctx, l.svcCtx.DB, req.Page, req.PageSize) + if err != nil { + return nil, err + } + + // Build user ID list for batch username lookup + userIds := make([]int64, len(quotas)) + for i, q := range quotas { + userIds[i] = q.UserId + } + + // Lookup usernames + usernameMap := make(map[int64]string) + if len(userIds) > 0 { + var users []model.User + l.svcCtx.DB.WithContext(l.ctx).Where("id IN ?", userIds).Select("id, username").Find(&users) + for _, u := range users { + usernameMap[u.Id] = u.Username + } + } + + list := make([]types.AIQuotaUserInfo, len(quotas)) + for i, q := range quotas { + list[i] = types.AIQuotaUserInfo{ + UserId: q.UserId, + Username: usernameMap[q.UserId], + Balance: q.Balance, + TotalRecharged: q.TotalRecharged, + TotalConsumed: q.TotalConsumed, + FrozenAmount: q.FrozenAmount, + } + } + + return &types.AIQuotaListResponse{ + List: list, + Total: total, + }, nil +} diff --git a/backend/internal/logic/ai/aiquotarechargelogic.go b/backend/internal/logic/ai/aiquotarechargelogic.go new file mode 100644 index 0000000..0e6a4c8 --- /dev/null +++ b/backend/internal/logic/ai/aiquotarechargelogic.go @@ -0,0 +1,43 @@ +package ai + +import ( + "context" + "errors" + + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/youruser/base/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AiQuotaRechargeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAiQuotaRechargeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiQuotaRechargeLogic { + return &AiQuotaRechargeLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AiQuotaRechargeLogic) AiQuotaRecharge(req *types.AIQuotaRechargeRequest) (resp *types.Response, err error) { + if req.Amount <= 0 { + return nil, errors.New("充值金额必须大于0") + } + + err = model.AIUserQuotaRecharge(l.ctx, l.svcCtx.DB, req.UserId, req.Amount) + if err != nil { + return nil, err + } + + return &types.Response{ + Code: 0, + Message: "充值成功", + Success: true, + }, nil +} diff --git a/backend/internal/logic/ai/aiusagerecordlistlogic.go b/backend/internal/logic/ai/aiusagerecordlistlogic.go new file mode 100644 index 0000000..5f8fc0f --- /dev/null +++ b/backend/internal/logic/ai/aiusagerecordlistlogic.go @@ -0,0 +1,89 @@ +package ai + +import ( + "context" + "fmt" + + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/youruser/base/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AiUsageRecordListLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAiUsageRecordListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiUsageRecordListLogic { + return &AiUsageRecordListLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AiUsageRecordListLogic) AiUsageRecordList(req *types.AIUsageRecordListRequest) (resp *types.AIUsageRecordListResponse, err error) { + // For non-admin users, the userId filter is enforced from context + // The route is user-level (Auth middleware), so we use request userId filter + // If userId=0 in request, it means show current user's records (from context) + userId := req.UserId + if userId == 0 { + uid, ok := l.ctx.Value("userId").(int64) + if ok { + userId = uid + } else if uidJson, ok2 := l.ctx.Value("userId").(float64); ok2 { + userId = int64(uidJson) + } + } + + records, total, err := model.AIUsageRecordFindList(l.ctx, l.svcCtx.DB, userId, req.ModelId, req.Status, req.Page, req.PageSize) + if err != nil { + return nil, err + } + + // Build lookup caches + userMap := make(map[int64]string) + providerMap := make(map[int64]string) + + for _, r := range records { + if _, ok := userMap[r.UserId]; !ok { + user, err := model.FindOne(l.ctx, l.svcCtx.DB, r.UserId) + if err == nil { + userMap[r.UserId] = user.Username + } + } + if _, ok := providerMap[r.ProviderId]; !ok { + provider, err := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, r.ProviderId) + if err == nil { + providerMap[r.ProviderId] = provider.DisplayName + } + } + } + + list := make([]types.AIUsageRecordInfo, len(records)) + for i, r := range records { + list[i] = types.AIUsageRecordInfo{ + Id: r.Id, + UserId: r.UserId, + Username: userMap[r.UserId], + ProviderId: r.ProviderId, + ProviderName: providerMap[r.ProviderId], + ModelId: r.ModelId, + InputTokens: r.InputTokens, + OutputTokens: r.OutputTokens, + Cost: r.Cost, + Status: r.Status, + LatencyMs: r.LatencyMs, + ErrorMessage: r.ErrorMessage, + CreatedAt: fmt.Sprintf("%s", r.CreatedAt.Format("2006-01-02 15:04:05")), + } + } + + return &types.AIUsageRecordListResponse{ + List: list, + Total: total, + }, nil +} diff --git a/backend/internal/logic/ai/aiusagestatslogic.go b/backend/internal/logic/ai/aiusagestatslogic.go new file mode 100644 index 0000000..ec515dc --- /dev/null +++ b/backend/internal/logic/ai/aiusagestatslogic.go @@ -0,0 +1,80 @@ +package ai + +import ( + "context" + + "github.com/youruser/base/internal/svc" + "github.com/youruser/base/internal/types" + "github.com/youruser/base/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AiUsageStatsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAiUsageStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiUsageStatsLogic { + return &AiUsageStatsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AiUsageStatsLogic) AiUsageStats(req *types.AIUsageStatsRequest) (resp *types.AIUsageStatsResponse, err error) { + days := req.Days + if days <= 0 { + days = 30 + } + + // Total stats + totalStats, err := model.AIUsageRecordTotalStats(l.ctx, l.svcCtx.DB, days) + if err != nil { + return nil, err + } + + // Model stats + modelResults, err := model.AIUsageRecordModelStats(l.ctx, l.svcCtx.DB, days) + if err != nil { + return nil, err + } + + modelStats := make([]types.AIModelStatItem, len(modelResults)) + for i, m := range modelResults { + modelStats[i] = types.AIModelStatItem{ + ModelId: m.ModelId, + Calls: m.Calls, + InputTokens: m.InputTokens, + OutputTokens: m.OutputTokens, + TotalCost: m.TotalCost, + } + } + + // Daily stats + dailyResults, err := model.AIUsageRecordDailyStats(l.ctx, l.svcCtx.DB, days) + if err != nil { + return nil, err + } + + dailyStats := make([]types.AIDailyStatItem, len(dailyResults)) + for i, d := range dailyResults { + dailyStats[i] = types.AIDailyStatItem{ + Date: d.Date, + Calls: d.Calls, + TotalTokens: d.TotalTokens, + TotalCost: d.TotalCost, + } + } + + return &types.AIUsageStatsResponse{ + TotalCalls: totalStats.TotalCalls, + TotalTokens: totalStats.TotalTokens, + TotalCost: totalStats.TotalCost, + TotalUsers: totalStats.TotalUsers, + ModelStats: modelStats, + DailyStats: dailyStats, + }, nil +} diff --git a/backend/internal/svc/servicecontext.go b/backend/internal/svc/servicecontext.go index 45e549d..bbe5573 100644 --- a/backend/internal/svc/servicecontext.go +++ b/backend/internal/svc/servicecontext.go @@ -282,6 +282,9 @@ func seedCasbinPolicies(enforcer *casbin.Enforcer) { {"user", "/api/v1/ai/key/:id", "PUT"}, {"user", "/api/v1/ai/key/:id", "DELETE"}, + // AI: user usage records + {"user", "/api/v1/ai/quota/records", "GET"}, + // AI: admin provider/model management {"admin", "/api/v1/ai/providers", "GET"}, {"admin", "/api/v1/ai/provider", "POST"}, @@ -290,6 +293,11 @@ func seedCasbinPolicies(enforcer *casbin.Enforcer) { {"admin", "/api/v1/ai/model", "POST"}, {"admin", "/api/v1/ai/model/:id", "PUT"}, {"admin", "/api/v1/ai/model/:id", "DELETE"}, + + // AI: admin quota/stats management + {"admin", "/api/v1/ai/quotas", "GET"}, + {"admin", "/api/v1/ai/quota/recharge", "POST"}, + {"admin", "/api/v1/ai/stats", "GET"}, } for _, p := range policies { @@ -330,10 +338,12 @@ func seedMenus(db *gorm.DB) { {Name: "AI 对话", Path: "/ai/chat", Icon: "MessageSquare", Type: "config", SortOrder: 5, Visible: true, Status: 1}, {Name: "AI 模型", Path: "/ai/models", Icon: "Cpu", Type: "config", SortOrder: 6, Visible: true, Status: 1}, {Name: "API 密钥", Path: "/ai/keys", Icon: "Key", Type: "config", SortOrder: 7, Visible: true, Status: 1}, - {Name: "角色管理", Path: "/roles", Icon: "Shield", Type: "config", SortOrder: 8, Visible: true, Status: 1}, - {Name: "菜单管理", Path: "/menus", Icon: "Menu", Type: "config", SortOrder: 9, Visible: true, Status: 1}, - {Name: "机构管理", Path: "/organizations", Icon: "Building2", Type: "config", SortOrder: 10, Visible: true, Status: 1}, - {Name: "设置", Path: "/settings", Icon: "Settings", Type: "default", SortOrder: 11, Visible: true, Status: 1}, + {Name: "用量统计", Path: "/ai/usage", Icon: "BarChart3", Type: "config", SortOrder: 8, Visible: true, Status: 1}, + {Name: "额度管理", Path: "/ai/quota", Icon: "Wallet", Type: "config", SortOrder: 9, Visible: true, Status: 1}, + {Name: "角色管理", Path: "/roles", Icon: "Shield", Type: "config", SortOrder: 10, Visible: true, Status: 1}, + {Name: "菜单管理", Path: "/menus", Icon: "Menu", Type: "config", SortOrder: 11, Visible: true, Status: 1}, + {Name: "机构管理", Path: "/organizations", Icon: "Building2", Type: "config", SortOrder: 12, Visible: true, Status: 1}, + {Name: "设置", Path: "/settings", Icon: "Settings", Type: "default", SortOrder: 13, Visible: true, Status: 1}, } for _, m := range menus { diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index ab5e0a6..4f9f0f7 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -120,6 +120,13 @@ type AIConversationUpdateRequest struct { Title string `json:"title"` } +type AIDailyStatItem struct { + Date string `json:"date"` + Calls int64 `json:"calls"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` +} + type AIMessageInfo struct { Id int64 `json:"id,string"` ConversationId int64 `json:"conversationId,string"` @@ -166,6 +173,14 @@ type AIModelListResponse struct { List []AIModelInfo `json:"list"` } +type AIModelStatItem struct { + ModelId string `json:"modelId"` + Calls int64 `json:"calls"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalCost float64 `json:"totalCost"` +} + type AIModelUpdateRequest struct { Id int64 `path:"id"` DisplayName string `json:"displayName,optional"` @@ -231,6 +246,73 @@ type AIQuotaInfo struct { FrozenAmount float64 `json:"frozenAmount"` } +type AIQuotaListRequest struct { + Page int64 `form:"page,optional,default=1"` + PageSize int64 `form:"pageSize,optional,default=20"` +} + +type AIQuotaListResponse struct { + List []AIQuotaUserInfo `json:"list"` + Total int64 `json:"total"` +} + +type AIQuotaRechargeRequest struct { + UserId int64 `json:"userId,string"` + Amount float64 `json:"amount"` + Remark string `json:"remark,optional"` +} + +type AIQuotaUserInfo struct { + UserId int64 `json:"userId,string"` + Username string `json:"username"` + Balance float64 `json:"balance"` + TotalRecharged float64 `json:"totalRecharged"` + TotalConsumed float64 `json:"totalConsumed"` + FrozenAmount float64 `json:"frozenAmount"` +} + +type AIUsageRecordInfo struct { + Id int64 `json:"id,string"` + UserId int64 `json:"userId,string"` + Username string `json:"username"` + ProviderId int64 `json:"providerId,string"` + ProviderName string `json:"providerName"` + ModelId string `json:"modelId"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + Cost float64 `json:"cost"` + Status string `json:"status"` + LatencyMs int `json:"latencyMs"` + ErrorMessage string `json:"errorMessage"` + CreatedAt string `json:"createdAt"` +} + +type AIUsageRecordListRequest struct { + Page int64 `form:"page,optional,default=1"` + PageSize int64 `form:"pageSize,optional,default=20"` + UserId int64 `form:"userId,optional"` + ModelId string `form:"modelId,optional"` + Status string `form:"status,optional"` +} + +type AIUsageRecordListResponse struct { + List []AIUsageRecordInfo `json:"list"` + Total int64 `json:"total"` +} + +type AIUsageStatsRequest struct { + Days int `form:"days,optional,default=30"` +} + +type AIUsageStatsResponse struct { + TotalCalls int64 `json:"totalCalls"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` + TotalUsers int64 `json:"totalUsers"` + ModelStats []AIModelStatItem `json:"modelStats"` + DailyStats []AIDailyStatItem `json:"dailyStats"` +} + type Activity struct { Id int64 `json:"id"` // 记录ID User string `json:"user"` // 用户邮箱 diff --git a/backend/model/ai_usage_record_model.go b/backend/model/ai_usage_record_model.go index e6ea645..c4d8052 100644 --- a/backend/model/ai_usage_record_model.go +++ b/backend/model/ai_usage_record_model.go @@ -37,3 +37,87 @@ func AIUsageRecordFindByUser(ctx context.Context, db *gorm.DB, userId int64, pag return records, total, nil } + +// AIUsageRecordFindList 查询使用记录(支持过滤,admin/user通用) +func AIUsageRecordFindList(ctx context.Context, db *gorm.DB, userId int64, modelId, status string, page, pageSize int64) ([]AIUsageRecord, int64, error) { + var records []AIUsageRecord + var total int64 + + query := db.WithContext(ctx).Model(&AIUsageRecord{}) + if userId > 0 { + query = query.Where("user_id = ?", userId) + } + if modelId != "" { + query = query.Where("model_id = ?", modelId) + } + if status != "" { + query = query.Where("status = ?", status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if offset < 0 { + offset = 0 + } + err := query.Order("created_at DESC").Offset(int(offset)).Limit(int(pageSize)).Find(&records).Error + return records, total, err +} + +// AIUsageRecordModelStats 按模型分组统计(指定天数内) +type ModelStatResult struct { + ModelId string `json:"modelId"` + Calls int64 `json:"calls"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalCost float64 `json:"totalCost"` +} + +func AIUsageRecordModelStats(ctx context.Context, db *gorm.DB, days int) ([]ModelStatResult, error) { + var results []ModelStatResult + err := db.WithContext(ctx).Model(&AIUsageRecord{}). + Select("model_id, COUNT(*) as calls, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, SUM(cost) as total_cost"). + Where("created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)", days). + Group("model_id"). + Order("total_cost DESC"). + Find(&results).Error + return results, err +} + +// AIUsageRecordDailyStats 按日统计(指定天数内) +type DailyStatResult struct { + Date string `json:"date"` + Calls int64 `json:"calls"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` +} + +func AIUsageRecordDailyStats(ctx context.Context, db *gorm.DB, days int) ([]DailyStatResult, error) { + var results []DailyStatResult + err := db.WithContext(ctx).Model(&AIUsageRecord{}). + Select("DATE(created_at) as date, COUNT(*) as calls, SUM(input_tokens + output_tokens) as total_tokens, SUM(cost) as total_cost"). + Where("created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)", days). + Group("DATE(created_at)"). + Order("date ASC"). + Find(&results).Error + return results, err +} + +// AIUsageRecordTotalStats 总体统计(指定天数内) +type TotalStatResult struct { + TotalCalls int64 `json:"totalCalls"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` + TotalUsers int64 `json:"totalUsers"` +} + +func AIUsageRecordTotalStats(ctx context.Context, db *gorm.DB, days int) (*TotalStatResult, error) { + var result TotalStatResult + err := db.WithContext(ctx).Model(&AIUsageRecord{}). + Select("COUNT(*) as total_calls, COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(cost), 0) as total_cost, COUNT(DISTINCT user_id) as total_users"). + Where("created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)", days). + Find(&result).Error + return &result, err +} diff --git a/backend/model/ai_user_quota_model.go b/backend/model/ai_user_quota_model.go index 721254c..add9840 100644 --- a/backend/model/ai_user_quota_model.go +++ b/backend/model/ai_user_quota_model.go @@ -73,6 +73,39 @@ func AIUserQuotaSettle(ctx context.Context, db *gorm.DB, userId int64, frozenAmo return nil } +// AIUserQuotaFindList 分页查询所有用户额度(admin用) +func AIUserQuotaFindList(ctx context.Context, db *gorm.DB, page, pageSize int64) ([]AIUserQuota, int64, error) { + var list []AIUserQuota + var total int64 + + query := db.WithContext(ctx).Model(&AIUserQuota{}) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if offset < 0 { + offset = 0 + } + err := query.Order("user_id ASC").Offset(int(offset)).Limit(int(pageSize)).Find(&list).Error + return list, total, err +} + +// AIUserQuotaRecharge 充值(原子操作:balance += amount, total_recharged += amount) +func AIUserQuotaRecharge(ctx context.Context, db *gorm.DB, userId int64, amount float64) error { + // Ensure record exists + _, err := AIUserQuotaEnsure(ctx, db, userId) + if err != nil { + return err + } + return db.WithContext(ctx).Model(&AIUserQuota{}). + Where("user_id = ?", userId). + Updates(map[string]interface{}{ + "balance": gorm.Expr("balance + ?", amount), + "total_recharged": gorm.Expr("total_recharged + ?", amount), + }).Error +} + // AIUserQuotaUnfreeze 解冻额度(原子操作:frozen_amount -= amount, balance += amount) func AIUserQuotaUnfreeze(ctx context.Context, db *gorm.DB, userId int64, amount float64) error { result := db.WithContext(ctx).Model(&AIUserQuota{}). diff --git a/frontend/react-shadcn/pc/src/App.tsx b/frontend/react-shadcn/pc/src/App.tsx index aef47c3..2ef7d3d 100644 --- a/frontend/react-shadcn/pc/src/App.tsx +++ b/frontend/react-shadcn/pc/src/App.tsx @@ -17,6 +17,8 @@ import { OrganizationManagementPage } from './pages/OrganizationManagementPage' import { AIChatPage } from './pages/AIChatPage' import { AIModelManagementPage } from './pages/AIModelManagementPage' import { AIKeyManagementPage } from './pages/AIKeyManagementPage' +import { AIUsagePage } from './pages/AIUsagePage' +import { AIQuotaManagementPage } from './pages/AIQuotaManagementPage' function App() { return ( @@ -46,6 +48,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx b/frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx index fb5a2d5..0698d3e 100644 --- a/frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx +++ b/frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx @@ -14,6 +14,8 @@ const pageTitles: Record = { '/ai/chat': { title: 'AI 对话', subtitle: '智能助手' }, '/ai/models': { title: 'AI 模型', subtitle: '管理平台与模型配置' }, '/ai/keys': { title: 'API 密钥', subtitle: '管理 API 访问密钥' }, + '/ai/usage': { title: '用量统计', subtitle: 'AI 调用记录与统计' }, + '/ai/quota': { title: '额度管理', subtitle: '管理用户 AI 额度' }, } export function MainLayout() { diff --git a/frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx b/frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx index fc2eb06..f27f2ad 100644 --- a/frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx +++ b/frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Users, LogOut, Settings, FolderOpen, Shield, Menu as MenuIcon, Building2, User, ChevronDown, - Cpu, Key, MessageSquare, + Cpu, Key, MessageSquare, BarChart3, Wallet, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { useAuth } from '@/contexts/AuthContext' @@ -12,7 +12,7 @@ import type { MenuItem } from '@/types' const iconMap: Record = { User, LayoutDashboard, Users, FolderOpen, Shield, Menu: MenuIcon, Building2, Settings, - Cpu, Key, MessageSquare, + Cpu, Key, MessageSquare, BarChart3, Wallet, } function getIcon(iconName: string): LucideIcon { diff --git a/frontend/react-shadcn/pc/src/pages/AIQuotaManagementPage.tsx b/frontend/react-shadcn/pc/src/pages/AIQuotaManagementPage.tsx new file mode 100644 index 0000000..bb8658f --- /dev/null +++ b/frontend/react-shadcn/pc/src/pages/AIQuotaManagementPage.tsx @@ -0,0 +1,331 @@ +import { useState, useEffect, useCallback } from 'react' +import { Search, Wallet, Plus } from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' +import { Modal } from '@/components/ui/Modal' +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from '@/components/ui/Table' +import type { AIQuotaUserInfo, AIQuotaRechargeRequest } from '@/types' +import { apiClient } from '@/services/api' + +export function AIQuotaManagementPage() { + const [quotas, setQuotas] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + + // Pagination + const [currentPage, setCurrentPage] = useState(1) + const [pageSize] = useState(20) + const [total, setTotal] = useState(0) + + // Recharge modal + const [rechargeModalOpen, setRechargeModalOpen] = useState(false) + const [rechargeUser, setRechargeUser] = useState(null) + const [rechargeAmount, setRechargeAmount] = useState('') + const [rechargeRemark, setRechargeRemark] = useState('') + const [isRecharging, setIsRecharging] = useState(false) + + // Fetch quotas + const fetchQuotas = useCallback(async () => { + try { + setIsLoading(true) + setError(null) + const response = await apiClient.getAIQuotas(currentPage, pageSize) + setQuotas(response.list || []) + setTotal(response.total || 0) + } catch (err) { + console.error('Failed to fetch AI quotas:', err) + setError('获取用户额度列表失败,请稍后重试') + setQuotas([]) + setTotal(0) + } finally { + setIsLoading(false) + } + }, [currentPage, pageSize]) + + useEffect(() => { + fetchQuotas() + }, [fetchQuotas]) + + // --- Recharge --- + + const openRechargeModal = (quota: AIQuotaUserInfo) => { + setRechargeUser(quota) + setRechargeAmount('') + setRechargeRemark('') + setRechargeModalOpen(true) + } + + const resetRechargeForm = () => { + setRechargeUser(null) + setRechargeAmount('') + setRechargeRemark('') + } + + const handleRecharge = async () => { + if (!rechargeUser) return + + const amount = parseFloat(rechargeAmount) + if (isNaN(amount) || amount <= 0) { + alert('请输入有效的充值金额(必须大于0)') + return + } + + try { + setIsRecharging(true) + const data: AIQuotaRechargeRequest = { + userId: rechargeUser.userId, + amount, + remark: rechargeRemark || undefined, + } + await apiClient.rechargeAIQuota(data) + setRechargeModalOpen(false) + resetRechargeForm() + await fetchQuotas() + } catch (err) { + console.error('Failed to recharge:', err) + alert('充值失败,请重试') + } finally { + setIsRecharging(false) + } + } + + // --- Pagination --- + + const totalPages = Math.ceil(total / pageSize) + const canGoPrev = currentPage > 1 + const canGoNext = currentPage < totalPages + + const handlePrevPage = () => { + if (canGoPrev) setCurrentPage(currentPage - 1) + } + + const handleNextPage = () => { + if (canGoNext) setCurrentPage(currentPage + 1) + } + + // --- Filtering --- + + const filteredQuotas = quotas.filter((quota) => + quota.username.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + // --- Format Currency --- + + const formatCurrency = (value: number): string => { + return `¥${value.toFixed(2)}` + } + + const getBalanceColor = (balance: number): string => { + if (balance > 0) return 'text-green-400' + if (balance < 0) return 'text-red-400' + return 'text-text-secondary' + } + + // --- Render --- + + return ( +
+ {/* Header */} + + +
+
+ setSearchQuery(e.target.value)} + leftIcon={} + /> +
+
+ + 共 {total} 个用户 + +
+
+
+
+ + {/* Error Message */} + {error && ( +
+ {error} + +
+ )} + + {/* Quota Table */} + + + + + 用户额度列表 + + + +
+ + + + 用户 + 余额 + 累计充值 + 累计消费 + 冻结金额 + 操作 + + + + {isLoading ? ( + + + 加载中... + + + ) : filteredQuotas.length === 0 ? ( + + + 暂无数据 + + + ) : ( + filteredQuotas.map((quota) => ( + + + {quota.username} + + + {formatCurrency(quota.balance)} + + + {formatCurrency(quota.totalRecharged)} + + + {formatCurrency(quota.totalConsumed)} + + + {formatCurrency(quota.frozenAmount)} + + + + + + )) + )} + +
+
+ + {/* Pagination */} + {!isLoading && totalPages > 1 && ( +
+
+ 第 {currentPage} 页,共 {totalPages} 页 +
+
+ + +
+
+ )} +
+
+ + {/* Recharge Modal */} + { + setRechargeModalOpen(false) + resetRechargeForm() + }} + title="充值额度" + size="md" + footer={ + <> + + + + } + > +
+
+ +
+ {rechargeUser?.username} +
+
+ + setRechargeAmount(e.target.value)} + min="0" + step="0.01" + required + /> + + setRechargeRemark(e.target.value)} + /> + +
+

+ 充值金额必须大于 0,充值后将立即生效。 +

+
+
+
+
+ ) +} diff --git a/frontend/react-shadcn/pc/src/pages/AIUsagePage.tsx b/frontend/react-shadcn/pc/src/pages/AIUsagePage.tsx new file mode 100644 index 0000000..0e39805 --- /dev/null +++ b/frontend/react-shadcn/pc/src/pages/AIUsagePage.tsx @@ -0,0 +1,351 @@ +import { useState, useEffect, useCallback } from 'react' +import { BarChart3, Activity, Coins, Users } from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from '@/components/ui/Table' +import type { AIUsageRecordInfo, AIUsageStats } from '@/types' +import { apiClient } from '@/services/api' + +export function AIUsagePage() { + // Stats state + const [stats, setStats] = useState(null) + const [isLoadingStats, setIsLoadingStats] = useState(true) + const [selectedPeriod, setSelectedPeriod] = useState<7 | 30 | 90>(30) + + // Records state + const [records, setRecords] = useState([]) + const [total, setTotal] = useState(0) + const [isLoadingRecords, setIsLoadingRecords] = useState(true) + const [error, setError] = useState(null) + + // Pagination state + const [page, setPage] = useState(1) + const [pageSize] = useState(20) + + // Fetch stats + const fetchStats = useCallback(async () => { + try { + setIsLoadingStats(true) + setError(null) + const data = await apiClient.getAIUsageStats(selectedPeriod) + setStats(data) + } catch (err) { + console.error('Failed to fetch AI usage stats:', err) + setError('获取统计数据失败,请稍后重试') + } finally { + setIsLoadingStats(false) + } + }, [selectedPeriod]) + + // Fetch records + const fetchRecords = useCallback(async () => { + try { + setIsLoadingRecords(true) + setError(null) + const response = await apiClient.getAIUsageRecords({ page, pageSize }) + setRecords(response.list || []) + setTotal(response.total || 0) + } catch (err) { + console.error('Failed to fetch AI usage records:', err) + setError('获取使用记录失败,请稍后重试') + setRecords([]) + } finally { + setIsLoadingRecords(false) + } + }, [page, pageSize]) + + useEffect(() => { + fetchStats() + }, [fetchStats]) + + useEffect(() => { + fetchRecords() + }, [fetchRecords]) + + // Format number with comma separators + const formatNumber = (num: number): string => { + return num.toLocaleString('zh-CN') + } + + // Format cost as currency + const formatCost = (cost: number): string => { + return `¥${cost.toFixed(2)}` + } + + // Format timestamp + const formatTimestamp = (timestamp: string): string => { + const date = new Date(timestamp) + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + } + + // Pagination handlers + const totalPages = Math.ceil(total / pageSize) + + const handlePrevPage = () => { + if (page > 1) { + setPage(page - 1) + } + } + + const handleNextPage = () => { + if (page < totalPages) { + setPage(page + 1) + } + } + + return ( +
+ {/* Period Selector */} +
+

AI 使用统计

+
+ + + +
+
+ + {/* Error Message */} + {error && ( +
+ {error} + +
+ )} + + {/* Stats Cards */} +
+ {/* Total Calls */} + + +
+
+

总调用次数

+ {isLoadingStats ? ( +

--

+ ) : ( +

+ {formatNumber(stats?.totalCalls || 0)} +

+ )} +
+
+ +
+
+
+
+ + {/* Total Tokens */} + + +
+
+

总 Token 数

+ {isLoadingStats ? ( +

--

+ ) : ( +

+ {formatNumber(stats?.totalTokens || 0)} +

+ )} +
+
+ +
+
+
+
+ + {/* Total Cost */} + + +
+
+

总费用

+ {isLoadingStats ? ( +

--

+ ) : ( +

+ {formatCost(stats?.totalCost || 0)} +

+ )} +
+
+ +
+
+
+
+ + {/* Active Users */} + + +
+
+

活跃用户数

+ {isLoadingStats ? ( +

--

+ ) : ( +

+ {formatNumber(stats?.totalUsers || 0)} +

+ )} +
+
+ +
+
+
+
+
+ + {/* Usage Records Table */} + + + 使用记录 ({total}) + + +
+ + + + 时间 + 用户 + 模型 + 输入Tokens + 输出Tokens + 费用 + 延迟 + 状态 + + + + {isLoadingRecords ? ( + + + 加载中... + + + ) : records.length === 0 ? ( + + + 暂无数据 + + + ) : ( + records.map((record) => ( + + + {formatTimestamp(record.createdAt)} + + + {record.username} + + + {record.modelId} + + + {formatNumber(record.inputTokens)} + + + {formatNumber(record.outputTokens)} + + + {formatCost(record.cost)} + + + {record.latencyMs}ms + + + {record.status === 'ok' ? ( + + 成功 + + ) : ( + + 失败 + + )} + + + )) + )} + +
+
+ + {/* Pagination */} + {!isLoadingRecords && records.length > 0 && ( +
+
+ 显示第 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} 条,共 {total} 条 +
+
+ +
+ 第 {page} / {totalPages} 页 +
+ +
+
+ )} +
+
+
+ ) +} diff --git a/frontend/react-shadcn/pc/src/services/api.ts b/frontend/react-shadcn/pc/src/services/api.ts index 6f2e5a1..74866f8 100644 --- a/frontend/react-shadcn/pc/src/services/api.ts +++ b/frontend/react-shadcn/pc/src/services/api.ts @@ -40,6 +40,10 @@ import type { AIApiKeyInfo, AIApiKeyCreateRequest, AIApiKeyUpdateRequest, + AIQuotaUserInfo, + AIQuotaRechargeRequest, + AIUsageRecordInfo, + AIUsageStats, } from '@/types' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1' @@ -545,6 +549,31 @@ class ApiClient { await this.request(`/ai/key/${id}`, { method: 'DELETE' }) } + // AI Quota Management (admin) + async getAIQuotas(page: number = 1, pageSize: number = 20): Promise<{ list: AIQuotaUserInfo[]; total: number }> { + return this.request<{ list: AIQuotaUserInfo[]; total: number }>(`/ai/quotas?page=${page}&pageSize=${pageSize}`) + } + + async rechargeAIQuota(data: AIQuotaRechargeRequest): Promise { + await this.request('/ai/quota/recharge', { method: 'POST', body: JSON.stringify(data) }) + } + + // AI Usage Records + async getAIUsageRecords(params: { page?: number; pageSize?: number; userId?: number; modelId?: string; status?: string } = {}): Promise<{ list: AIUsageRecordInfo[]; total: number }> { + const q = new URLSearchParams() + if (params.page) q.append('page', params.page.toString()) + if (params.pageSize) q.append('pageSize', params.pageSize.toString()) + if (params.userId) q.append('userId', params.userId.toString()) + if (params.modelId) q.append('modelId', params.modelId) + if (params.status) q.append('status', params.status) + return this.request<{ list: AIUsageRecordInfo[]; total: number }>(`/ai/quota/records?${q}`) + } + + // AI Usage Stats (admin) + async getAIUsageStats(days: number = 30): Promise { + return this.request(`/ai/stats?days=${days}`) + } + // Health check async healthCheck(): Promise<{ status: string }> { try { diff --git a/frontend/react-shadcn/pc/src/types/index.ts b/frontend/react-shadcn/pc/src/types/index.ts index e96b463..5f146a0 100644 --- a/frontend/react-shadcn/pc/src/types/index.ts +++ b/frontend/react-shadcn/pc/src/types/index.ts @@ -419,3 +419,61 @@ export interface AIApiKeyUpdateRequest { isActive?: boolean remark?: string } + +// AI Quota Management Types +export interface AIQuotaUserInfo { + userId: number + username: string + balance: number + totalRecharged: number + totalConsumed: number + frozenAmount: number +} + +export interface AIQuotaRechargeRequest { + userId: number + amount: number + remark?: string +} + +// AI Usage Record Types +export interface AIUsageRecordInfo { + id: number + userId: number + username: string + providerId: number + providerName: string + modelId: string + inputTokens: number + outputTokens: number + cost: number + status: string + latencyMs: number + errorMessage: string + createdAt: string +} + +// AI Stats Types +export interface AIUsageStats { + totalCalls: number + totalTokens: number + totalCost: number + totalUsers: number + modelStats: AIModelStatItem[] + dailyStats: AIDailyStatItem[] +} + +export interface AIModelStatItem { + modelId: string + calls: number + inputTokens: number + outputTokens: number + totalCost: number +} + +export interface AIDailyStatItem { + date: string + calls: number + totalTokens: number + totalCost: number +}