From 2f1e357deaefc137b5075c730a9c2fea35cd3a78 Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 12:26:54 +0800 Subject: [PATCH 1/8] task(T2.1,T2.2): implement balance group user stats repo and service Add GetBalanceGroupUserStats to repository with CTE aggregation query, JOIN with users table, ILIKE search with metacharacter escaping, and whitelisted sort columns. Add service method with parameter validation including 90-day max range and sort column whitelist. Co-Authored-By: Claude Opus 4.5 --- backend/internal/repository/usage_log_repo.go | 136 ++++++++++++++++++ .../internal/service/account_usage_service.go | 3 + backend/internal/service/usage_service.go | 38 +++++ 3 files changed, 177 insertions(+) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 2db1764fa..1b959b3bf 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -2386,3 +2386,139 @@ func setToSlice(set map[int64]struct{}) []int64 { } return out } + +// BalanceGroupUserStats type alias +type BalanceGroupUserStats = usagestats.BalanceGroupUserStats +type BalanceGroupUserStatsResponse = usagestats.BalanceGroupUserStatsResponse +type BalanceGroupUserStatsParams = usagestats.BalanceGroupUserStatsParams + +// GetBalanceGroupUserStats returns aggregated usage statistics per user for a balance group within a time range. +func (r *usageLogRepository) GetBalanceGroupUserStats(ctx context.Context, params *BalanceGroupUserStatsParams) (resp *BalanceGroupUserStatsResponse, err error) { + // Build dynamic WHERE clause for search + args := []any{params.GroupID, *params.StartDate, *params.EndDate} + argPos := 4 + + searchClause := "" + if params.Search != "" { + searchClause = fmt.Sprintf(" AND (u.email ILIKE $%d OR u.username ILIKE $%d)", argPos, argPos+1) + // Escape ILIKE metacharacters to prevent unintended wildcard matching + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(params.Search) + searchPattern := "%" + escaped + "%" + args = append(args, searchPattern, searchPattern) + argPos += 2 + } + + // Validate and set sort_by + sortColumn := "total_cost" + allowedSorts := map[string]string{ + "total_cost": "total_cost", + "actual_cost": "actual_cost", + "total_requests": "total_requests", + "input_tokens": "input_tokens", + "output_tokens": "output_tokens", + "cache_read_tokens": "cache_read_tokens", + "balance": "balance", + } + if col, ok := allowedSorts[params.SortBy]; ok { + sortColumn = col + } + + sortOrder := "DESC" + if strings.EqualFold(params.SortOrder, "asc") { + sortOrder = "ASC" + } + + // Count query for total + countQuery := fmt.Sprintf(` + WITH usage_agg AS ( + SELECT user_id + FROM usage_logs + WHERE group_id = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY user_id + ) + SELECT COUNT(DISTINCT ua.user_id) + FROM usage_agg ua + JOIN users u ON u.id = ua.user_id + WHERE 1=1%s + `, searchClause) + + var total int64 + if err := scanSingleRow(ctx, r.sql, countQuery, args, &total); err != nil { + return nil, err + } + + // Data query with pagination + limit := params.PageSize + offset := (params.Page - 1) * params.PageSize + + dataArgs := make([]any, len(args)) + copy(dataArgs, args) + limitPos := argPos + offsetPos := argPos + 1 + dataArgs = append(dataArgs, limit, offset) + + dataQuery := fmt.Sprintf(` + WITH usage_agg AS ( + SELECT + user_id, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as actual_cost, + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as input_tokens, + COALESCE(SUM(output_tokens), 0) as output_tokens, + COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens + FROM usage_logs + WHERE group_id = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY user_id + ) + SELECT + ua.user_id, + COALESCE(u.email, '') as email, + COALESCE(u.username, '') as username, + COALESCE(u.balance, 0) as balance, + ua.total_cost, + ua.actual_cost, + ua.total_requests, + ua.input_tokens, + ua.output_tokens, + ua.cache_read_tokens + FROM usage_agg ua + JOIN users u ON u.id = ua.user_id + WHERE 1=1%s + ORDER BY %s %s + LIMIT $%d OFFSET $%d + `, searchClause, sortColumn, sortOrder, limitPos, offsetPos) + + rows, err := r.sql.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + resp = nil + } + }() + + users := make([]BalanceGroupUserStats, 0) + for rows.Next() { + var s BalanceGroupUserStats + if err = rows.Scan( + &s.UserID, &s.Email, &s.Username, &s.Balance, + &s.TotalCost, &s.ActualCost, &s.TotalRequests, + &s.InputTokens, &s.OutputTokens, &s.CacheReadTokens, + ); err != nil { + return nil, err + } + users = append(users, s) + } + if err = rows.Err(); err != nil { + return nil, err + } + + resp = &BalanceGroupUserStatsResponse{ + Users: users, + Total: total, + } + return resp, nil +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 304c57811..d0ddc3289 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -53,6 +53,9 @@ type UsageLogRepository interface { // Account stats GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) + // Balance group stats + GetBalanceGroupUserStats(ctx context.Context, params *usagestats.BalanceGroupUserStatsParams) (*usagestats.BalanceGroupUserStatsResponse, error) + // Aggregated stats (optimized) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index 5594e53f8..df9104a86 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -350,3 +350,41 @@ func (s *UsageService) GetStatsWithFilters(ctx context.Context, filters usagesta } return stats, nil } + +// GetBalanceGroupUserStats returns aggregated usage statistics per user for a balance group. +func (s *UsageService) GetBalanceGroupUserStats(ctx context.Context, params *usagestats.BalanceGroupUserStatsParams) (*usagestats.BalanceGroupUserStatsResponse, error) { + if params.GroupID <= 0 { + return nil, fmt.Errorf("group_id is required") + } + if params.StartDate == nil || params.EndDate == nil { + return nil, fmt.Errorf("start_date and end_date are required") + } + if params.EndDate.Before(*params.StartDate) { + return nil, fmt.Errorf("end_date must be after start_date") + } + // 日期范围不超过 90 天 + if params.EndDate.Sub(*params.StartDate).Hours() > 90*24 { + return nil, fmt.Errorf("date range must not exceed 90 days") + } + if params.Page < 1 { + params.Page = 1 + } + if params.PageSize < 1 || params.PageSize > 100 { + params.PageSize = 20 + } + // sort_by 白名单校验 + allowedSortBy := map[string]bool{ + "total_cost": true, + "actual_cost": true, + "total_requests": true, + "input_tokens": true, + "output_tokens": true, + "cache_read_tokens": true, + "balance": true, + } + if params.SortBy != "" && !allowedSortBy[params.SortBy] { + params.SortBy = "total_cost" + } + + return s.usageRepo.GetBalanceGroupUserStats(ctx, params) +} From 14e28a1631485371247d892431ae3a9005d34792 Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 12:27:08 +0800 Subject: [PATCH 2/8] task(T2.3): add balance handler, DI wiring, and admin routes Create BalanceHandler with GetStats endpoint parsing query params. Register in wire ProviderSet, AdminHandlers struct, and wire_gen.go. Add GET /admin/balance/stats route. Co-Authored-By: Claude Opus 4.5 --- backend/cmd/server/wire_gen.go | 3 +- .../internal/handler/admin/balance_handler.go | 82 +++++++++++++++++++ backend/internal/handler/handler.go | 1 + backend/internal/handler/wire.go | 3 + backend/internal/server/routes/admin.go | 10 +++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/admin/balance_handler.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 47b1e8ac5..07572041b 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -173,7 +173,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) + balanceHandler := admin.NewBalanceHandler(usageService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, balanceHandler) gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, configConfig) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) diff --git a/backend/internal/handler/admin/balance_handler.go b/backend/internal/handler/admin/balance_handler.go new file mode 100644 index 000000000..921d35a35 --- /dev/null +++ b/backend/internal/handler/admin/balance_handler.go @@ -0,0 +1,82 @@ +package admin + +import ( + "net/http" + "strconv" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// BalanceHandler handles admin balance management +type BalanceHandler struct { + usageService *service.UsageService +} + +// NewBalanceHandler creates a new admin balance handler +func NewBalanceHandler(usageService *service.UsageService) *BalanceHandler { + return &BalanceHandler{ + usageService: usageService, + } +} + +// GetStats handles GET /api/v1/admin/balance/stats +func (h *BalanceHandler) GetStats(c *gin.Context) { + groupIDStr := c.Query("group_id") + if groupIDStr == "" { + response.Error(c, http.StatusBadRequest, "group_id is required") + return + } + groupID, err := strconv.ParseInt(groupIDStr, 10, 64) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid group_id") + return + } + + startDateStr := c.Query("start_date") + endDateStr := c.Query("end_date") + if startDateStr == "" || endDateStr == "" { + response.Error(c, http.StatusBadRequest, "start_date and end_date are required") + return + } + + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD") + return + } + endDate, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid end_date format, expected YYYY-MM-DD") + return + } + // end_date 需要加一天,以包含当天的数据 + endDate = endDate.AddDate(0, 0, 1) + + page, pageSize := response.ParsePagination(c) + sortBy := c.DefaultQuery("sort_by", "total_cost") + sortOrder := c.DefaultQuery("sort_order", "desc") + search := c.Query("search") + + params := &usagestats.BalanceGroupUserStatsParams{ + GroupID: groupID, + StartDate: &startDate, + EndDate: &endDate, + Page: page, + PageSize: pageSize, + SortBy: sortBy, + SortOrder: sortOrder, + Search: search, + } + + result, err := h.usageService.GetBalanceGroupUserStats(c.Request.Context(), params) + if err != nil { + response.Error(c, http.StatusBadRequest, err.Error()) + return + } + + response.Success(c, result) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index b8f7d417e..8e51244ee 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -24,6 +24,7 @@ type AdminHandlers struct { Subscription *admin.SubscriptionHandler Usage *admin.UsageHandler UserAttribute *admin.UserAttributeHandler + Balance *admin.BalanceHandler } // Handlers contains all HTTP handlers diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 48a3794b7..beaacfb57 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -27,6 +27,7 @@ func ProvideAdminHandlers( subscriptionHandler *admin.SubscriptionHandler, usageHandler *admin.UsageHandler, userAttributeHandler *admin.UserAttributeHandler, + balanceHandler *admin.BalanceHandler, ) *AdminHandlers { return &AdminHandlers{ Dashboard: dashboardHandler, @@ -47,6 +48,7 @@ func ProvideAdminHandlers( Subscription: subscriptionHandler, Usage: usageHandler, UserAttribute: userAttributeHandler, + Balance: balanceHandler, } } @@ -125,6 +127,7 @@ var ProviderSet = wire.NewSet( admin.NewSubscriptionHandler, admin.NewUsageHandler, admin.NewUserAttributeHandler, + admin.NewBalanceHandler, // AdminHandlers and Handlers constructors ProvideAdminHandlers, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ca9d627e6..a2b611afe 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -65,6 +65,9 @@ func RegisterAdminRoutes( // 使用记录管理 registerUsageRoutes(admin, h) + // 余额管理 + registerBalanceRoutes(admin, h) + // 用户属性管理 registerUserAttributeRoutes(admin, h) } @@ -387,3 +390,10 @@ func registerUserAttributeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { attrs.DELETE("/:id", h.Admin.UserAttribute.DeleteDefinition) } } + +func registerBalanceRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + balance := admin.Group("/balance") + { + balance.GET("/stats", h.Admin.Balance.GetStats) + } +} From 8becaf3f286ea6cbf364b226f2ad9ebf7a8203f7 Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 12:27:22 +0800 Subject: [PATCH 3/8] task(T2.4): add frontend balance API module Create balance.ts with TypeScript interfaces and getBalanceGroupUserStats API function. Register in admin API barrel export. Co-Authored-By: Claude Opus 4.5 --- frontend/src/api/admin/balance.ts | 61 +++++++++++++++++++++++++++++++ frontend/src/api/admin/index.ts | 7 +++- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/admin/balance.ts diff --git a/frontend/src/api/admin/balance.ts b/frontend/src/api/admin/balance.ts new file mode 100644 index 000000000..d5366864e --- /dev/null +++ b/frontend/src/api/admin/balance.ts @@ -0,0 +1,61 @@ +/** + * Admin Balance API endpoints + * Handles balance group user statistics for administrators + */ + +import { apiClient } from '../client' + +/** Aggregated usage statistics for a single user in a balance group */ +export interface BalanceGroupUserStats { + user_id: number + email: string + username: string + balance: number + total_cost: number + actual_cost: number + total_requests: number + input_tokens: number + output_tokens: number + cache_read_tokens: number +} + +/** Paginated response for balance group user stats */ +export interface BalanceGroupUserStatsResponse { + users: BalanceGroupUserStats[] + total: number +} + +/** Query parameters for balance group user stats */ +export interface BalanceGroupUserStatsParams { + group_id: number + start_date: string + end_date: string + page?: number + page_size?: number + sort_by?: string + sort_order?: 'asc' | 'desc' + search?: string +} + +/** + * Get balance group user statistics + * @param params - Query parameters + * @returns Paginated user stats response + */ +export async function getBalanceGroupUserStats( + params: BalanceGroupUserStatsParams, + options?: { signal?: AbortSignal } +): Promise { + const { data } = await apiClient.get( + '/admin/balance/stats', + { + params, + signal: options?.signal + } + ) + return data +} + +export default { + getBalanceGroupUserStats +} diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 9a8a41958..0dc359314 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -19,6 +19,7 @@ import geminiAPI from './gemini' import antigravityAPI from './antigravity' import userAttributesAPI from './userAttributes' import opsAPI from './ops' +import balanceAPI from './balance' /** * Unified admin API object for convenient access @@ -39,7 +40,8 @@ export const adminAPI = { gemini: geminiAPI, antigravity: antigravityAPI, userAttributes: userAttributesAPI, - ops: opsAPI + ops: opsAPI, + balance: balanceAPI } export { @@ -58,7 +60,8 @@ export { geminiAPI, antigravityAPI, userAttributesAPI, - opsAPI + opsAPI, + balanceAPI } export default adminAPI From 2e3c491a5bc85dcf8180f2caae0b8660dd92a439 Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 12:27:36 +0800 Subject: [PATCH 4/8] task(T3.1-T3.4): add BalanceView admin page Implement full-featured balance management page with group selector (standard type only), date range picker (90-day max), debounced search, server-side sort/pagination DataTable, model distribution dialog, balance deposit modal, and balance history modal. Includes empty states and cost/token formatting helpers. Co-Authored-By: Claude Opus 4.5 --- frontend/src/views/admin/BalanceView.vue | 528 +++++++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 frontend/src/views/admin/BalanceView.vue diff --git a/frontend/src/views/admin/BalanceView.vue b/frontend/src/views/admin/BalanceView.vue new file mode 100644 index 000000000..0023fbc90 --- /dev/null +++ b/frontend/src/views/admin/BalanceView.vue @@ -0,0 +1,528 @@ + + + From 12a66e1dd85134e7bb8b47a0cedd6500c43645cf Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 12:27:50 +0800 Subject: [PATCH 5/8] task(T4.1,T4.2): add i18n translations, sidebar nav, and route Add admin.balance namespace translations for zh and en locales. Add WalletIcon and balance nav item to admin sidebar (hidden in simple mode). Register /admin/balance route with lazy loading. Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/layout/AppSidebar.vue | 21 ++++++++++ frontend/src/i18n/locales/en.ts | 39 ++++++++++++++++++ frontend/src/i18n/locales/zh.ts | 41 ++++++++++++++++++- frontend/src/router/index.ts | 12 ++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index e0c4212ad..db4b6e67d 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -289,6 +289,26 @@ const CreditCardIcon = { ) } +const WalletIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 110-6h.008A2.251 2.251 0 0117.25 1.5h.5A2.25 2.25 0 0120 3.75v.443c.572.227 1.072.58 1.462 1.031C22.07 5.93 22.5 6.9 22.5 8.25v7.5c0 1.35-.43 2.32-1.038 3.026A3.733 3.733 0 0120 19.807v.443A2.25 2.25 0 0117.75 22.5h-.5a2.251 2.251 0 01-2.242-2.25H15a3 3 0 110-6h3.75A2.25 2.25 0 0021 12z' + }), + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M15.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z' + }) + ] + ) +} + const GlobeIcon = { render: () => h( @@ -484,6 +504,7 @@ const adminNavItems = computed(() => { { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, + { path: '/admin/balance', label: t('nav.balance'), icon: WalletIcon, hideInSimpleMode: true }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fb255c1a2..abb63ce66 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -195,6 +195,7 @@ export default { users: 'Users', groups: 'Groups', subscriptions: 'Subscriptions', + balance: 'Balance', accounts: 'Accounts', proxies: 'Proxies', redeemCodes: 'Redeem Codes', @@ -429,6 +430,15 @@ export default { geminiCli: 'Gemini CLI', codexCli: 'Codex CLI', opencode: 'OpenCode', + ccSwitch: 'CC Switch', + }, + ccSwitch: { + basicConfigTitle: 'Basic Config', + basicConfigHint: 'Fill in the following info when adding a provider in CC Switch:', + baseUrlLabel: 'Base URL', + usageConfigTitle: 'Usage Query Extractor Code', + usageConfigHint: + 'Configure usage query: After adding a provider, select it → click "Configure Usage Query" tab → enable usage query → click "Custom Preset Template" → paste the following code into the "Extractor Code" field.', }, antigravity: { description: 'Configure API access for Antigravity group. Select the configuration method based on your client.', @@ -1175,6 +1185,35 @@ export default { "Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone." }, + // Balance Management + balance: { + title: 'Balance Management', + description: 'View balance group user usage statistics', + selectGroup: 'Select Group', + selectGroupHint: 'Please select a group', + selectGroupHintDesc: 'Select a balance billing group above to view user usage data', + noStandardGroups: 'No Balance Groups', + noStandardGroupsDesc: 'No groups configured with balance billing type (standard)', + searchPlaceholder: 'Search email/username', + noData: 'No Data', + noDataDesc: 'No user usage data found for the current filters', + modelDist: 'Models', + modelDistTitle: 'Model Distribution for {user}', + noModelData: 'No model distribution data', + history: 'History', + columns: { + user: 'User', + balance: 'Balance', + totalCost: 'Standard Cost', + actualCost: 'Actual Cost', + requests: 'Requests', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + cacheTokens: 'Cache Tokens', + actions: 'Actions' + } + }, + // Accounts accounts: { title: 'Account Management', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index e964aae24..bfd37ca48 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -192,6 +192,7 @@ export default { users: '用户管理', groups: '分组管理', subscriptions: '订阅管理', + balance: '余额管理', accounts: '账号管理', proxies: 'IP管理', redeemCodes: '兑换码', @@ -427,7 +428,16 @@ export default { claudeCode: 'Claude Code', geminiCli: 'Gemini CLI', codexCli: 'Codex CLI', - opencode: 'OpenCode' + opencode: 'OpenCode', + ccSwitch: 'CC Switch' + }, + ccSwitch: { + basicConfigTitle: '基础配置', + basicConfigHint: '在 CC Switch 中添加供应商时,请填写以下信息:', + baseUrlLabel: '请求地址', + usageConfigTitle: '用量查询提取器代码', + usageConfigHint: + '配置用量查询:添加完供应商后,选中该供应商 → 点击「配置用量查询」子标签 → 点击「启用用量查询」→ 点击「自定义预设模板」→ 将以下代码拷贝到「提取器代码」中即可。' }, antigravity: { description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。', @@ -1260,7 +1270,34 @@ export default { revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。" }, - // Accounts Management + // Balance Management + balance: { + title: '余额管理', + description: '查看余额分组用户用量统计', + selectGroup: '选择分组', + selectGroupHint: '请选择一个分组', + selectGroupHintDesc: '从上方选择一个余额计费分组以查看用户用量数据', + noStandardGroups: '暂无余额计费分组', + noStandardGroupsDesc: '当前没有配置余额计费类型(standard)的分组', + searchPlaceholder: '搜索邮箱/用户名', + noData: '暂无数据', + noDataDesc: '当前条件下没有找到用户用量数据', + modelDist: '模型分布', + modelDistTitle: '{user} 的模型分布', + noModelData: '暂无模型分布数据', + history: '充值历史', + columns: { + user: '用户', + balance: '余额', + totalCost: '标准计费', + actualCost: '实际扣除', + requests: '请求次数', + inputTokens: '输入 Token', + outputTokens: '输出 Token', + cacheTokens: '缓存 Token', + actions: '操作' + } + }, accounts: { title: '账号管理', description: '管理 AI 平台账号和 Cookie', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4bb46cee5..2f8dabb26 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -253,6 +253,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.subscriptions.description' } }, + { + path: '/admin/balance', + name: 'AdminBalance', + component: () => import('@/views/admin/BalanceView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Balance Management', + titleKey: 'admin.balance.title', + descriptionKey: 'admin.balance.description' + } + }, { path: '/admin/accounts', name: 'AdminAccounts', From 39943cb82ca687c94b7776477ba85cbb908513d7 Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 14:14:28 +0800 Subject: [PATCH 6/8] fix(balance): address code review findings - Handler: distinguish validation errors (400) from internal errors (500), log internal errors server-side instead of exposing to client - Repo: add nil pointer guard for StartDate/EndDate parameters - Frontend: add AbortController for request cancellation to prevent race conditions, clean up debounce timer on unmount, remove unused watch import Co-Authored-By: Claude Opus 4.5 --- backend/internal/handler/admin/balance_handler.go | 13 ++++++++++++- backend/internal/repository/usage_log_repo.go | 4 ++++ frontend/src/views/admin/BalanceView.vue | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/admin/balance_handler.go b/backend/internal/handler/admin/balance_handler.go index 921d35a35..f7c98d3e8 100644 --- a/backend/internal/handler/admin/balance_handler.go +++ b/backend/internal/handler/admin/balance_handler.go @@ -1,8 +1,10 @@ package admin import ( + "log/slog" "net/http" "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -74,7 +76,16 @@ func (h *BalanceHandler) GetStats(c *gin.Context) { result, err := h.usageService.GetBalanceGroupUserStats(c.Request.Context(), params) if err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) + // Service validation errors contain safe messages; internal errors should not be exposed + errMsg := err.Error() + if strings.Contains(errMsg, "is required") || + strings.Contains(errMsg, "must be after") || + strings.Contains(errMsg, "must not exceed") { + response.Error(c, http.StatusBadRequest, errMsg) + } else { + slog.Error("failed to get balance group user stats", "error", err) + response.Error(c, http.StatusInternalServerError, "failed to get balance stats") + } return } diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 1b959b3bf..14d5d5854 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -2394,6 +2394,10 @@ type BalanceGroupUserStatsParams = usagestats.BalanceGroupUserStatsParams // GetBalanceGroupUserStats returns aggregated usage statistics per user for a balance group within a time range. func (r *usageLogRepository) GetBalanceGroupUserStats(ctx context.Context, params *BalanceGroupUserStatsParams) (resp *BalanceGroupUserStatsResponse, err error) { + if params.StartDate == nil || params.EndDate == nil { + return nil, fmt.Errorf("start_date and end_date are required") + } + // Build dynamic WHERE clause for search args := []any{params.GroupID, *params.StartDate, *params.EndDate} argPos := 4 diff --git a/frontend/src/views/admin/BalanceView.vue b/frontend/src/views/admin/BalanceView.vue index 0023fbc90..0966ba70a 100644 --- a/frontend/src/views/admin/BalanceView.vue +++ b/frontend/src/views/admin/BalanceView.vue @@ -229,7 +229,7 @@ From c86bcec48d65747cb570fd7626c31323af2596e8 Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 12:26:40 +0800 Subject: [PATCH 7/8] task(T1.1,T1.2): add balance group user stats types and database index Add BalanceGroupUserStats, BalanceGroupUserStatsResponse, and BalanceGroupUserStatsParams type definitions. Add composite index on (group_id, created_at) for usage_log to optimize balance queries. Co-Authored-By: Claude Opus 4.5 --- backend/ent/schema/usage_log.go | 1 + .../pkg/usagestats/usage_log_types.go | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index fc7c71652..426d420f8 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -170,5 +170,6 @@ func (UsageLog) Indexes() []ent.Index { // 复合索引用于时间范围查询 index.Fields("user_id", "created_at"), index.Fields("api_key_id", "created_at"), + index.Fields("group_id", "created_at"), } } diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 2f6c7fe0b..4db2b4532 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -226,3 +226,35 @@ type AccountUsageStatsResponse struct { Summary AccountUsageSummary `json:"summary"` Models []ModelStat `json:"models"` } + +// BalanceGroupUserStats represents aggregated usage statistics for a single user in a balance group +type BalanceGroupUserStats struct { + UserID int64 `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + Balance float64 `json:"balance"` + TotalCost float64 `json:"total_cost"` + ActualCost float64 `json:"actual_cost"` + TotalRequests int64 `json:"total_requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` +} + +// BalanceGroupUserStatsResponse represents the paginated response for balance group user stats +type BalanceGroupUserStatsResponse struct { + Users []BalanceGroupUserStats `json:"users"` + Total int64 `json:"total"` +} + +// BalanceGroupUserStatsParams represents the query parameters for balance group user stats +type BalanceGroupUserStatsParams struct { + GroupID int64 `json:"group_id"` + StartDate *time.Time `json:"start_date"` + EndDate *time.Time `json:"end_date"` + Page int `json:"page"` + PageSize int `json:"page_size"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` + Search string `json:"search"` +} From 576b67876fdc1bff95e980783164463dd1a34b8e Mon Sep 17 00:00:00 2001 From: JIA-ss <627723154@qq.com> Date: Thu, 5 Feb 2026 14:40:28 +0800 Subject: [PATCH 8/8] fix: resolve CI failures for balance management feature - Fix gofmt formatting issue in usage_log_repo.go (map alignment) - Add missing GetBalanceGroupUserStats method to stubUsageLogRepo in tests Co-Authored-By: Claude Opus 4.5 --- backend/internal/repository/usage_log_repo.go | 12 ++++++------ backend/internal/server/api_contract_test.go | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 14d5d5854..514892d66 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -2415,13 +2415,13 @@ func (r *usageLogRepository) GetBalanceGroupUserStats(ctx context.Context, param // Validate and set sort_by sortColumn := "total_cost" allowedSorts := map[string]string{ - "total_cost": "total_cost", - "actual_cost": "actual_cost", - "total_requests": "total_requests", - "input_tokens": "input_tokens", - "output_tokens": "output_tokens", + "total_cost": "total_cost", + "actual_cost": "actual_cost", + "total_requests": "total_requests", + "input_tokens": "input_tokens", + "output_tokens": "output_tokens", "cache_read_tokens": "cache_read_tokens", - "balance": "balance", + "balance": "balance", } if col, ok := allowedSorts[params.SortBy]; ok { sortColumn = col diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index e197b776d..d9440bf74 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1671,6 +1671,10 @@ func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usag return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetBalanceGroupUserStats(ctx context.Context, params *usagestats.BalanceGroupUserStatsParams) (*usagestats.BalanceGroupUserStatsResponse, error) { + return nil, errors.New("not implemented") +} + type stubSettingRepo struct { all map[string]string }