From 961c30e7c04d80163b80725a82f9631e375b505b Mon Sep 17 00:00:00 2001 From: QTom Date: Tue, 17 Mar 2026 22:09:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20=E5=88=86=E7=BB=84=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=88=97=E8=A1=A8=E6=96=B0=E5=A2=9E=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E5=88=97=E4=B8=8E=E8=B4=A6=E5=8F=B7=E6=95=B0=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组管理列表增强: 1. 今日/累计用量列: - 新增独立端点 GET /admin/groups/usage-summary - 一次查询返回所有分组的今日费用和累计费用(actual_cost) - 前端异步加载后合并显示在分组列表中 2. 账号数区分可用/限流/总量: - 将账号数列从单一总量改为 badge 内多行展示 - 可用: active + schedulable 的账号数(绿色) - 限流: rate_limit/overload/temp_unschedulable 的账号数(橙色,无限流时隐藏) - 总量: 全部关联账号数 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/cmd/server/wire_gen.go | 2 +- .../admin/admin_basic_handlers_test.go | 4 +- .../internal/handler/admin/group_handler.go | 25 ++++++- backend/internal/handler/dto/mappers.go | 4 +- backend/internal/handler/dto/types.go | 4 +- ...eway_handler_warmup_intercept_unit_test.go | 2 +- .../handler/sora_gateway_handler_test.go | 4 +- .../pkg/usagestats/usage_log_types.go | 7 ++ backend/internal/repository/group_repo.go | 69 ++++++++++++++----- .../repository/group_repo_integration_test.go | 8 +-- backend/internal/repository/usage_log_repo.go | 38 ++++++++++ backend/internal/server/routes/admin.go | 1 + .../internal/service/account_usage_service.go | 1 + .../service/admin_service_apikey_test.go | 2 +- .../service/admin_service_delete_test.go | 2 +- .../service/admin_service_group_test.go | 6 +- backend/internal/service/dashboard_service.go | 9 +++ .../service/gateway_multiplatform_test.go | 4 +- .../service/gemini_multiplatform_test.go | 4 +- backend/internal/service/group.go | 6 +- backend/internal/service/group_service.go | 4 +- .../service/sora_quota_service_test.go | 4 +- .../subscription_assign_idempotency_test.go | 2 +- frontend/src/api/admin/groups.ts | 19 ++++- frontend/src/i18n/locales/en.ts | 7 ++ frontend/src/i18n/locales/zh.ts | 7 ++ frontend/src/types/index.ts | 2 + frontend/src/views/admin/GroupsView.vue | 65 +++++++++++++++-- 28 files changed, 257 insertions(+), 55 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index f632bff3..a2451672 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -110,7 +110,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService) - groupHandler := admin.NewGroupHandler(adminService) + groupHandler := admin.NewGroupHandler(adminService, dashboardService) claudeOAuthClient := repository.NewClaudeOAuthClient() oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) openAIOAuthClient := repository.NewOpenAIOAuthClient() diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 4de10d3e..4b5f1bfb 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -17,8 +17,8 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { adminSvc := newStubAdminService() userHandler := NewUserHandler(adminSvc, nil) - groupHandler := NewGroupHandler(adminSvc) - proxyHandler := NewProxyHandler(adminSvc) + groupHandler := NewGroupHandler(adminSvc, nil) + proxyHandler := NewProxyHandler(adminSvc, nil, nil) redeemHandler := NewRedeemHandler(adminSvc, nil) router.GET("/api/v1/admin/users", userHandler.List) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 4ffe64ee..abdba78c 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -16,7 +17,8 @@ import ( // GroupHandler handles admin group management type GroupHandler struct { - adminService service.AdminService + adminService service.AdminService + dashboardService *service.DashboardService } type optionalLimitField struct { @@ -69,9 +71,10 @@ func (f optionalLimitField) ToServiceInput() *float64 { } // NewGroupHandler creates a new admin group handler -func NewGroupHandler(adminService service.AdminService) *GroupHandler { +func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService) *GroupHandler { return &GroupHandler{ - adminService: adminService, + adminService: adminService, + dashboardService: dashboardService, } } @@ -363,6 +366,22 @@ func (h *GroupHandler) GetStats(c *gin.Context) { _ = groupID // TODO: implement actual stats } +// GetUsageSummary returns today's and cumulative cost for all groups. +// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai +func (h *GroupHandler) GetUsageSummary(c *gin.Context) { + userTZ := c.Query("timezone") + now := timezone.NowInUserLocation(userTZ) + todayStart := timezone.StartOfDayInUserLocation(now, userTZ) + + results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart) + if err != nil { + response.Error(c, 500, "Failed to get group usage summary") + return + } + + response.Success(c, results) +} + // GetGroupAPIKeys handles getting API keys in a group // GET /api/v1/admin/groups/:id/api-keys func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 8e5f23e7..bb6bb594 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -141,7 +141,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { MCPXMLInject: g.MCPXMLInject, DefaultMappedModel: g.DefaultMappedModel, SupportedModelScopes: g.SupportedModelScopes, - AccountCount: g.AccountCount, + AccountCount: g.AccountCount, + ActiveAccountCount: g.ActiveAccountCount, + RateLimitedAccountCount: g.RateLimitedAccountCount, SortOrder: g.SortOrder, } if len(g.AccountGroups) > 0 { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index c52e357e..3ee3ac29 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -124,7 +124,9 @@ type AdminGroup struct { // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` - AccountCount int64 `json:"account_count,omitempty"` + AccountCount int64 `json:"account_count,omitempty"` + ActiveAccountCount int64 `json:"active_account_count,omitempty"` + RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` // 分组排序 SortOrder int `json:"sort_order"` diff --git a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go index 6bcc0003..b9dbe0ce 100644 --- a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go +++ b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go @@ -76,7 +76,7 @@ func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service return nil, nil } func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil } -func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, error) { return 0, nil } +func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil } func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go index 06b09437..8066a656 100644 --- a/backend/internal/handler/sora_gateway_handler_test.go +++ b/backend/internal/handler/sora_gateway_handler_test.go @@ -273,8 +273,8 @@ func (r *stubGroupRepo) ListActiveByPlatform(ctx context.Context, platform strin func (r *stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (r *stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index f42a746f..91b63638 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -90,6 +90,13 @@ type EndpointStat struct { ActualCost float64 `json:"actual_cost"` // 实际扣除 } +// GroupUsageSummary represents today's and cumulative cost for a single group. +type GroupUsageSummary struct { + GroupID int64 `json:"group_id"` + TodayCost float64 `json:"today_cost"` + TotalCost float64 `json:"total_cost"` +} + // GroupStat represents usage statistics for a single group type GroupStat struct { GroupID int64 `json:"group_id"` diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index c195f1f1..674c655b 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -88,8 +88,9 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group if err != nil { return nil, err } - count, _ := r.GetAccountCount(ctx, out.ID) - out.AccountCount = count + total, active, _ := r.GetAccountCount(ctx, out.ID) + out.AccountCount = total + out.ActiveAccountCount = active return out, nil } @@ -256,7 +257,10 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -283,7 +287,10 @@ func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, erro counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -310,7 +317,10 @@ func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform str counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -369,12 +379,20 @@ func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int return result, nil } -func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - var count int64 - if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &count); err != nil { - return 0, err - } - return count, nil +func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) { + var rateLimited int64 + err = scanSingleRow(ctx, r.sql, + `SELECT COUNT(*), + COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true), + COUNT(*) FILTER (WHERE a.status = 'active' AND ( + a.rate_limit_reset_at > NOW() OR + a.overload_until > NOW() OR + a.temp_unschedulable_until > NOW() + )) + FROM account_groups ag JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = $1`, + []any{groupID}, &total, &active, &rateLimited) + return } func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { @@ -500,15 +518,32 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, return affectedUserIDs, nil } -func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]int64, err error) { - counts = make(map[int64]int64, len(groupIDs)) +type groupAccountCounts struct { + Total int64 + Active int64 + RateLimited int64 +} + +func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]groupAccountCounts, err error) { + counts = make(map[int64]groupAccountCounts, len(groupIDs)) if len(groupIDs) == 0 { return counts, nil } rows, err := r.sql.QueryContext( ctx, - "SELECT group_id, COUNT(*) FROM account_groups WHERE group_id = ANY($1) GROUP BY group_id", + `SELECT ag.group_id, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true) AS active, + COUNT(*) FILTER (WHERE a.status = 'active' AND ( + a.rate_limit_reset_at > NOW() OR + a.overload_until > NOW() OR + a.temp_unschedulable_until > NOW() + )) AS rate_limited + FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = ANY($1) + GROUP BY ag.group_id`, pq.Array(groupIDs), ) if err != nil { @@ -523,11 +558,11 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6 for rows.Next() { var groupID int64 - var count int64 - if err = rows.Scan(&groupID, &count); err != nil { + var c groupAccountCounts + if err = rows.Scan(&groupID, &c.Total, &c.Active, &c.RateLimited); err != nil { return nil, err } - counts[groupID] = count + counts[groupID] = c } if err = rows.Err(); err != nil { return nil, err diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 4a849a46..eccf5cea 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -603,7 +603,7 @@ func (s *GroupRepoSuite) TestGetAccountCount() { _, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a2, group.ID, 2) s.Require().NoError(err) - count, err := s.repo.GetAccountCount(s.ctx, group.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, group.ID) s.Require().NoError(err, "GetAccountCount") s.Require().Equal(int64(2), count) } @@ -619,7 +619,7 @@ func (s *GroupRepoSuite) TestGetAccountCount_Empty() { } s.Require().NoError(s.repo.Create(s.ctx, group)) - count, err := s.repo.GetAccountCount(s.ctx, group.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, group.ID) s.Require().NoError(err) s.Require().Zero(count) } @@ -651,7 +651,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID() { s.Require().NoError(err, "DeleteAccountGroupsByGroupID") s.Require().Equal(int64(1), affected, "expected 1 affected row") - count, err := s.repo.GetAccountCount(s.ctx, g.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, g.ID) s.Require().NoError(err, "GetAccountCount") s.Require().Equal(int64(0), count, "expected 0 account groups") } @@ -692,7 +692,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID_MultipleAccounts() { s.Require().NoError(err) s.Require().Equal(int64(3), affected) - count, _ := s.repo.GetAccountCount(s.ctx, g.ID) + count, _, _ := s.repo.GetAccountCount(s.ctx, g.ID) s.Require().Zero(count) } diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index dcdaeaee..ca77bb90 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -3000,6 +3000,7 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start return results, nil } +<<<<<<< HEAD // GetUserBreakdownStats returns per-user usage breakdown within a specific dimension. func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) { query := ` @@ -3067,6 +3068,43 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim return results, nil } +// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group. +// todayStart is the start-of-day in the caller's timezone (UTC-based). +// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation. +// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s) +// or a materialized view / pre-aggregation table for cumulative costs. +func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + query := ` + SELECT + g.id AS group_id, + COALESCE(SUM(ul.actual_cost), 0) AS total_cost, + COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost + FROM groups g + LEFT JOIN usage_logs ul ON ul.group_id = g.id + GROUP BY g.id + ` + + rows, err := r.sql.QueryContext(ctx, query, todayStart) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []usagestats.GroupUsageSummary + for rows.Next() { + var row usagestats.GroupUsageSummary + if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil { +>>>>>>> c8c1b4d4 (feat(admin): 分组管理列表新增用量列与账号数分类) + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + // resolveEndpointColumn maps endpoint type to the corresponding DB column name. func resolveEndpointColumn(endpointType string) string { switch endpointType { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 67d7cb45..5f41cfad 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -227,6 +227,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { groups.GET("", h.Admin.Group.List) groups.GET("/all", h.Admin.Group.GetAll) + groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary) groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder) groups.GET("/:id", h.Admin.Group.GetByID) groups.POST("", h.Admin.Group.Create) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 4a05c64a..74142700 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -49,6 +49,7 @@ type UsageLogRepository interface { GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) + GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index 88d2f492..7588c16d 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -194,7 +194,7 @@ func (s *groupRepoStubForGroupUpdate) ListActiveByPlatform(context.Context, stri func (s *groupRepoStubForGroupUpdate) ExistsByName(context.Context, string) (bool, error) { panic("unexpected") } -func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, error) { +func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, int64, error) { panic("unexpected") } func (s *groupRepoStubForGroupUpdate) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 2e0f7d90..662b4771 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -160,7 +160,7 @@ func (s *groupRepoStub) ExistsByName(ctx context.Context, name string) (bool, er panic("unexpected ExistsByName call") } -func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { +func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index ef77a980..536be0b5 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -100,7 +100,7 @@ func (s *groupRepoStubForAdmin) ExistsByName(_ context.Context, _ string) (bool, panic("unexpected ExistsByName call") } -func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } @@ -383,7 +383,7 @@ func (s *groupRepoStubForFallbackCycle) ExistsByName(_ context.Context, _ string panic("unexpected ExistsByName call") } -func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } @@ -458,7 +458,7 @@ func (s *groupRepoStubForInvalidRequestFallback) ExistsByName(_ context.Context, panic("unexpected ExistsByName call") } -func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index ad29990f..c04e1428 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -148,6 +148,15 @@ func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTi return stats, nil } +// GetGroupUsageSummary returns today's and cumulative cost for all groups. +func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart) + if err != nil { + return nil, fmt.Errorf("get group usage summary: %w", err) + } + return results, nil +} + func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) { data, err := s.cache.GetDashboardStats(ctx) if err != nil { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index ea8fa784..718cd42a 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -278,8 +278,8 @@ func (m *mockGroupRepoForGateway) ListActiveByPlatform(ctx context.Context, plat func (m *mockGroupRepoForGateway) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index b0b804eb..a78c56e7 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -230,8 +230,8 @@ func (m *mockGroupRepoForGemini) ListActiveByPlatform(ctx context.Context, platf func (m *mockGroupRepoForGemini) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 537b5a3b..e17032e0 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -64,8 +64,10 @@ type Group struct { CreatedAt time.Time UpdatedAt time.Time - AccountGroups []AccountGroup - AccountCount int64 + AccountGroups []AccountGroup + AccountCount int64 + ActiveAccountCount int64 + RateLimitedAccountCount int64 } func (g *Group) IsActive() bool { diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 22a67eda..87174e03 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -27,7 +27,7 @@ type GroupRepository interface { ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) ExistsByName(ctx context.Context, name string) (bool, error) - GetAccountCount(ctx context.Context, groupID int64) (int64, error) + GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) // GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) @@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any, } // 获取账号数量 - accountCount, err := s.groupRepo.GetAccountCount(ctx, id) + accountCount, _, err := s.groupRepo.GetAccountCount(ctx, id) if err != nil { return nil, fmt.Errorf("get account count: %w", err) } diff --git a/backend/internal/service/sora_quota_service_test.go b/backend/internal/service/sora_quota_service_test.go index 040e427d..da8efe77 100644 --- a/backend/internal/service/sora_quota_service_test.go +++ b/backend/internal/service/sora_quota_service_test.go @@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([ func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) { return false, nil } -func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, error) { - return 0, nil +func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, int64, error) { + return 0, 0, nil } func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 0defafba..619bebf4 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) { panic("unexpected ExistsByName call") } -func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, error) { +func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 7c2658fa..5c63ea95 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -218,6 +218,22 @@ export async function batchSetGroupRateMultipliers( return data } +/** + * Get usage summary (today + cumulative cost) for all groups + * @param timezone - IANA timezone string (e.g. "Asia/Shanghai") + * @returns Array of group usage summaries + */ +export async function getUsageSummary( + timezone?: string +): Promise<{ group_id: number; today_cost: number; total_cost: number }[]> { + const { data } = await apiClient.get< + { group_id: number; today_cost: number; total_cost: number }[] + >('/admin/groups/usage-summary', { + params: timezone ? { timezone } : undefined + }) + return data +} + export const groupsAPI = { list, getAll, @@ -232,7 +248,8 @@ export const groupsAPI = { getGroupRateMultipliers, clearGroupRateMultipliers, batchSetGroupRateMultipliers, - updateSortOrder + updateSortOrder, + getUsageSummary } export default groupsAPI diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 26edcfe9..b7a301b2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1505,6 +1505,7 @@ export default { rateMultiplier: 'Rate Multiplier', type: 'Type', accounts: 'Accounts', + usage: 'Usage', status: 'Status', actions: 'Actions', billingType: 'Billing Type', @@ -1513,6 +1514,12 @@ export default { userNotes: 'Notes', userStatus: 'Status' }, + usageToday: 'Today', + usageTotal: 'Total', + accountsAvailable: 'Avail:', + accountsRateLimited: 'Limited:', + accountsTotal: 'Total:', + accountsUnit: '', rateAndAccounts: '{rate}x rate · {count} accounts', accountsCount: '{count} accounts', form: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 39c900ca..324c5f8e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1561,6 +1561,7 @@ export default { priority: '优先级', apiKeys: 'API 密钥数', accounts: '账号数', + usage: '用量', status: '状态', actions: '操作', billingType: '计费类型', @@ -1569,6 +1570,12 @@ export default { userNotes: '备注', userStatus: '状态' }, + usageToday: '今日', + usageTotal: '累计', + accountsAvailable: '可用:', + accountsRateLimited: '限流:', + accountsTotal: '总量:', + accountsUnit: '个账号', form: { name: '名称', description: '描述', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dffc0d20..70e6dc42 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -411,6 +411,8 @@ export interface AdminGroup extends Group { // 分组下账号数量(仅管理员可见) account_count?: number + active_account_count?: number + rate_limited_account_count?: number // OpenAI Messages 调度配置(仅 openai 平台使用) default_mapped_model?: string diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index f8ee39e9..36e968df 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -158,12 +158,38 @@ -