Skip to content

Commit e6326b2

Browse files
authored
Merge pull request Wei-Shaw#1108 from DaydreamCoding/feat/admin-group-capacity-and-usage
feat(admin): 分组管理列表新增用量、账号分类与容量列
2 parents 17cdceb + d4cc987 commit e6326b2

32 files changed

Lines changed: 553 additions & 65 deletions

backend/cmd/server/wire_gen.go

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/handler/admin/admin_basic_handlers_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
1717
adminSvc := newStubAdminService()
1818

1919
userHandler := NewUserHandler(adminSvc, nil)
20-
groupHandler := NewGroupHandler(adminSvc)
20+
groupHandler := NewGroupHandler(adminSvc, nil, nil)
2121
proxyHandler := NewProxyHandler(adminSvc)
2222
redeemHandler := NewRedeemHandler(adminSvc, nil)
2323

backend/internal/handler/admin/group_handler.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ import (
99

1010
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
1111
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
12+
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
1213
"github.com/Wei-Shaw/sub2api/internal/service"
1314

1415
"github.com/gin-gonic/gin"
1516
)
1617

1718
// GroupHandler handles admin group management
1819
type GroupHandler struct {
19-
adminService service.AdminService
20+
adminService service.AdminService
21+
dashboardService *service.DashboardService
22+
groupCapacityService *service.GroupCapacityService
2023
}
2124

2225
type optionalLimitField struct {
@@ -69,9 +72,11 @@ func (f optionalLimitField) ToServiceInput() *float64 {
6972
}
7073

7174
// NewGroupHandler creates a new admin group handler
72-
func NewGroupHandler(adminService service.AdminService) *GroupHandler {
75+
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
7376
return &GroupHandler{
74-
adminService: adminService,
77+
adminService: adminService,
78+
dashboardService: dashboardService,
79+
groupCapacityService: groupCapacityService,
7580
}
7681
}
7782

@@ -363,6 +368,33 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
363368
_ = groupID // TODO: implement actual stats
364369
}
365370

371+
// GetUsageSummary returns today's and cumulative cost for all groups.
372+
// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai
373+
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
374+
userTZ := c.Query("timezone")
375+
now := timezone.NowInUserLocation(userTZ)
376+
todayStart := timezone.StartOfDayInUserLocation(now, userTZ)
377+
378+
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
379+
if err != nil {
380+
response.Error(c, 500, "Failed to get group usage summary")
381+
return
382+
}
383+
384+
response.Success(c, results)
385+
}
386+
387+
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
388+
// GET /api/v1/admin/groups/capacity-summary
389+
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
390+
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
391+
if err != nil {
392+
response.Error(c, 500, "Failed to get group capacity summary")
393+
return
394+
}
395+
response.Success(c, results)
396+
}
397+
366398
// GetGroupAPIKeys handles getting API keys in a group
367399
// GET /api/v1/admin/groups/:id/api-keys
368400
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {

backend/internal/handler/dto/mappers.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,16 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
135135
return nil
136136
}
137137
out := &AdminGroup{
138-
Group: groupFromServiceBase(g),
139-
ModelRouting: g.ModelRouting,
140-
ModelRoutingEnabled: g.ModelRoutingEnabled,
141-
MCPXMLInject: g.MCPXMLInject,
142-
DefaultMappedModel: g.DefaultMappedModel,
143-
SupportedModelScopes: g.SupportedModelScopes,
144-
AccountCount: g.AccountCount,
145-
SortOrder: g.SortOrder,
138+
Group: groupFromServiceBase(g),
139+
ModelRouting: g.ModelRouting,
140+
ModelRoutingEnabled: g.ModelRoutingEnabled,
141+
MCPXMLInject: g.MCPXMLInject,
142+
DefaultMappedModel: g.DefaultMappedModel,
143+
SupportedModelScopes: g.SupportedModelScopes,
144+
AccountCount: g.AccountCount,
145+
ActiveAccountCount: g.ActiveAccountCount,
146+
RateLimitedAccountCount: g.RateLimitedAccountCount,
147+
SortOrder: g.SortOrder,
146148
}
147149
if len(g.AccountGroups) > 0 {
148150
out.AccountGroups = make([]AccountGroup, 0, len(g.AccountGroups))

backend/internal/handler/dto/types.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,11 @@ type AdminGroup struct {
122122
DefaultMappedModel string `json:"default_mapped_model"`
123123

124124
// 支持的模型系列(仅 antigravity 平台使用)
125-
SupportedModelScopes []string `json:"supported_model_scopes"`
126-
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
127-
AccountCount int64 `json:"account_count,omitempty"`
125+
SupportedModelScopes []string `json:"supported_model_scopes"`
126+
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
127+
AccountCount int64 `json:"account_count,omitempty"`
128+
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
129+
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
128130

129131
// 分组排序
130132
SortOrder int `json:"sort_order"`

backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service
7676
return nil, nil
7777
}
7878
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
79-
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, error) { return 0, nil }
79+
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil }
8080
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
8181
return 0, nil
8282
}

backend/internal/handler/sora_gateway_handler_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,8 @@ func (r *stubGroupRepo) ListActiveByPlatform(ctx context.Context, platform strin
273273
func (r *stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error) {
274274
return false, nil
275275
}
276-
func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
277-
return 0, nil
276+
func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) {
277+
return 0, 0, nil
278278
}
279279
func (r *stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
280280
return 0, nil
@@ -348,6 +348,9 @@ func (s *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTi
348348
func (s *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
349349
return nil, nil
350350
}
351+
func (s *stubUsageLogRepo) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
352+
return nil, nil
353+
}
351354
func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
352355
return nil, nil
353356
}

backend/internal/pkg/usagestats/usage_log_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ type EndpointStat struct {
9090
ActualCost float64 `json:"actual_cost"` // 实际扣除
9191
}
9292

93+
// GroupUsageSummary represents today's and cumulative cost for a single group.
94+
type GroupUsageSummary struct {
95+
GroupID int64 `json:"group_id"`
96+
TodayCost float64 `json:"today_cost"`
97+
TotalCost float64 `json:"total_cost"`
98+
}
99+
93100
// GroupStat represents usage statistics for a single group
94101
type GroupStat struct {
95102
GroupID int64 `json:"group_id"`

backend/internal/repository/group_repo.go

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group
8888
if err != nil {
8989
return nil, err
9090
}
91-
count, _ := r.GetAccountCount(ctx, out.ID)
92-
out.AccountCount = count
91+
total, active, _ := r.GetAccountCount(ctx, out.ID)
92+
out.AccountCount = total
93+
out.ActiveAccountCount = active
9394
return out, nil
9495
}
9596

@@ -256,7 +257,10 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination
256257
counts, err := r.loadAccountCounts(ctx, groupIDs)
257258
if err == nil {
258259
for i := range outGroups {
259-
outGroups[i].AccountCount = counts[outGroups[i].ID]
260+
c := counts[outGroups[i].ID]
261+
outGroups[i].AccountCount = c.Total
262+
outGroups[i].ActiveAccountCount = c.Active
263+
outGroups[i].RateLimitedAccountCount = c.RateLimited
260264
}
261265
}
262266

@@ -283,7 +287,10 @@ func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, erro
283287
counts, err := r.loadAccountCounts(ctx, groupIDs)
284288
if err == nil {
285289
for i := range outGroups {
286-
outGroups[i].AccountCount = counts[outGroups[i].ID]
290+
c := counts[outGroups[i].ID]
291+
outGroups[i].AccountCount = c.Total
292+
outGroups[i].ActiveAccountCount = c.Active
293+
outGroups[i].RateLimitedAccountCount = c.RateLimited
287294
}
288295
}
289296

@@ -310,7 +317,10 @@ func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform str
310317
counts, err := r.loadAccountCounts(ctx, groupIDs)
311318
if err == nil {
312319
for i := range outGroups {
313-
outGroups[i].AccountCount = counts[outGroups[i].ID]
320+
c := counts[outGroups[i].ID]
321+
outGroups[i].AccountCount = c.Total
322+
outGroups[i].ActiveAccountCount = c.Active
323+
outGroups[i].RateLimitedAccountCount = c.RateLimited
314324
}
315325
}
316326

@@ -369,12 +379,20 @@ func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int
369379
return result, nil
370380
}
371381

372-
func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
373-
var count int64
374-
if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &count); err != nil {
375-
return 0, err
376-
}
377-
return count, nil
382+
func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) {
383+
var rateLimited int64
384+
err = scanSingleRow(ctx, r.sql,
385+
`SELECT COUNT(*),
386+
COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true),
387+
COUNT(*) FILTER (WHERE a.status = 'active' AND (
388+
a.rate_limit_reset_at > NOW() OR
389+
a.overload_until > NOW() OR
390+
a.temp_unschedulable_until > NOW()
391+
))
392+
FROM account_groups ag JOIN accounts a ON a.id = ag.account_id
393+
WHERE ag.group_id = $1`,
394+
[]any{groupID}, &total, &active, &rateLimited)
395+
return
378396
}
379397

380398
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,
500518
return affectedUserIDs, nil
501519
}
502520

503-
func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]int64, err error) {
504-
counts = make(map[int64]int64, len(groupIDs))
521+
type groupAccountCounts struct {
522+
Total int64
523+
Active int64
524+
RateLimited int64
525+
}
526+
527+
func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]groupAccountCounts, err error) {
528+
counts = make(map[int64]groupAccountCounts, len(groupIDs))
505529
if len(groupIDs) == 0 {
506530
return counts, nil
507531
}
508532

509533
rows, err := r.sql.QueryContext(
510534
ctx,
511-
"SELECT group_id, COUNT(*) FROM account_groups WHERE group_id = ANY($1) GROUP BY group_id",
535+
`SELECT ag.group_id,
536+
COUNT(*) AS total,
537+
COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true) AS active,
538+
COUNT(*) FILTER (WHERE a.status = 'active' AND (
539+
a.rate_limit_reset_at > NOW() OR
540+
a.overload_until > NOW() OR
541+
a.temp_unschedulable_until > NOW()
542+
)) AS rate_limited
543+
FROM account_groups ag
544+
JOIN accounts a ON a.id = ag.account_id
545+
WHERE ag.group_id = ANY($1)
546+
GROUP BY ag.group_id`,
512547
pq.Array(groupIDs),
513548
)
514549
if err != nil {
@@ -523,11 +558,11 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6
523558

524559
for rows.Next() {
525560
var groupID int64
526-
var count int64
527-
if err = rows.Scan(&groupID, &count); err != nil {
561+
var c groupAccountCounts
562+
if err = rows.Scan(&groupID, &c.Total, &c.Active, &c.RateLimited); err != nil {
528563
return nil, err
529564
}
530-
counts[groupID] = count
565+
counts[groupID] = c
531566
}
532567
if err = rows.Err(); err != nil {
533568
return nil, err

backend/internal/repository/group_repo_integration_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ func (s *GroupRepoSuite) TestGetAccountCount() {
603603
_, 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)
604604
s.Require().NoError(err)
605605

606-
count, err := s.repo.GetAccountCount(s.ctx, group.ID)
606+
count, _, err := s.repo.GetAccountCount(s.ctx, group.ID)
607607
s.Require().NoError(err, "GetAccountCount")
608608
s.Require().Equal(int64(2), count)
609609
}
@@ -619,7 +619,7 @@ func (s *GroupRepoSuite) TestGetAccountCount_Empty() {
619619
}
620620
s.Require().NoError(s.repo.Create(s.ctx, group))
621621

622-
count, err := s.repo.GetAccountCount(s.ctx, group.ID)
622+
count, _, err := s.repo.GetAccountCount(s.ctx, group.ID)
623623
s.Require().NoError(err)
624624
s.Require().Zero(count)
625625
}
@@ -651,7 +651,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID() {
651651
s.Require().NoError(err, "DeleteAccountGroupsByGroupID")
652652
s.Require().Equal(int64(1), affected, "expected 1 affected row")
653653

654-
count, err := s.repo.GetAccountCount(s.ctx, g.ID)
654+
count, _, err := s.repo.GetAccountCount(s.ctx, g.ID)
655655
s.Require().NoError(err, "GetAccountCount")
656656
s.Require().Equal(int64(0), count, "expected 0 account groups")
657657
}
@@ -692,7 +692,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID_MultipleAccounts() {
692692
s.Require().NoError(err)
693693
s.Require().Equal(int64(3), affected)
694694

695-
count, _ := s.repo.GetAccountCount(s.ctx, g.ID)
695+
count, _, _ := s.repo.GetAccountCount(s.ctx, g.ID)
696696
s.Require().Zero(count)
697697
}
698698

0 commit comments

Comments
 (0)