Skip to content

Commit dcd70c6

Browse files
feat: add account usage statistics view
1 parent a1106e8 commit dcd70c6

11 files changed

Lines changed: 883 additions & 1 deletion

File tree

backend/internal/handler/admin/account_handler.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,6 +1667,49 @@ func (h *AccountHandler) GetUsage(c *gin.Context) {
16671667
response.Success(c, usage)
16681668
}
16691669

1670+
// GetRecentUsers handles getting recent users of an account
1671+
// GET /api/v1/admin/accounts/:id/recent-users
1672+
func (h *AccountHandler) GetRecentUsers(c *gin.Context) {
1673+
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
1674+
if err != nil {
1675+
response.BadRequest(c, "Invalid account ID")
1676+
return
1677+
}
1678+
1679+
var users []service.RecentAccountUser
1680+
startDateStr := strings.TrimSpace(c.Query("start_date"))
1681+
endDateStr := strings.TrimSpace(c.Query("end_date"))
1682+
if startDateStr != "" || endDateStr != "" {
1683+
if startDateStr == "" || endDateStr == "" {
1684+
response.BadRequest(c, "start_date and end_date are required together")
1685+
return
1686+
}
1687+
userTZ := c.Query("timezone")
1688+
startTime, parseErr := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
1689+
if parseErr != nil {
1690+
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
1691+
return
1692+
}
1693+
endTime, parseErr := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
1694+
if parseErr != nil {
1695+
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
1696+
return
1697+
}
1698+
users, err = h.accountUsageService.GetAccountUsersByTimeRange(c.Request.Context(), accountID, startTime, endTime.AddDate(0, 0, 1))
1699+
} else {
1700+
users, err = h.accountUsageService.GetRecentAccountUsers(c.Request.Context(), accountID, 5)
1701+
}
1702+
if err != nil {
1703+
response.ErrorFrom(c, err)
1704+
return
1705+
}
1706+
if users == nil {
1707+
users = []service.RecentAccountUser{}
1708+
}
1709+
1710+
response.Success(c, gin.H{"users": users})
1711+
}
1712+
16701713
// ClearRateLimit handles clearing account rate limit status
16711714
// POST /api/v1/admin/accounts/:id/clear-rate-limit
16721715
func (h *AccountHandler) ClearRateLimit(c *gin.Context) {

backend/internal/repository/usage_log_repo.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4392,3 +4392,69 @@ func setToSlice(set map[int64]struct{}) []int64 {
43924392
}
43934393
return out
43944394
}
4395+
4396+
// GetRecentAccountUsers returns users who used the account in the last N minutes
4397+
func (r *usageLogRepository) GetRecentAccountUsers(ctx context.Context, accountID int64, minutes int) ([]service.RecentAccountUser, error) {
4398+
query := `
4399+
SELECT
4400+
ul.user_id,
4401+
COALESCE(u.email, '') as email,
4402+
COUNT(*) as requests,
4403+
COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost,
4404+
COALESCE(SUM(ul.actual_cost), 0) as user_cost,
4405+
MAX(ul.created_at) as last_used_at
4406+
FROM usage_logs ul
4407+
LEFT JOIN users u ON u.id = ul.user_id
4408+
WHERE ul.account_id = $1 AND ul.created_at >= NOW() - make_interval(mins => $2)
4409+
GROUP BY ul.user_id, u.email
4410+
ORDER BY last_used_at DESC
4411+
LIMIT 50
4412+
`
4413+
rows, err := r.db.QueryContext(ctx, query, accountID, minutes)
4414+
if err != nil {
4415+
return nil, err
4416+
}
4417+
defer rows.Close()
4418+
var result []service.RecentAccountUser
4419+
for rows.Next() {
4420+
var u service.RecentAccountUser
4421+
if err := rows.Scan(&u.UserID, &u.Email, &u.Requests, &u.AccountCost, &u.UserCost, &u.LastUsedAt); err != nil {
4422+
return nil, err
4423+
}
4424+
result = append(result, u)
4425+
}
4426+
return result, rows.Err()
4427+
}
4428+
4429+
// GetAccountUsersByTimeRange returns users who used the account within a selected time range.
4430+
func (r *usageLogRepository) GetAccountUsersByTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]service.RecentAccountUser, error) {
4431+
query := `
4432+
SELECT
4433+
ul.user_id,
4434+
COALESCE(u.email, '') as email,
4435+
COUNT(*) as requests,
4436+
COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost,
4437+
COALESCE(SUM(ul.actual_cost), 0) as user_cost,
4438+
MAX(ul.created_at) as last_used_at
4439+
FROM usage_logs ul
4440+
LEFT JOIN users u ON u.id = ul.user_id
4441+
WHERE ul.account_id = $1 AND ul.created_at >= $2 AND ul.created_at < $3
4442+
GROUP BY ul.user_id, u.email
4443+
ORDER BY requests DESC, last_used_at DESC
4444+
LIMIT 200
4445+
`
4446+
rows, err := r.db.QueryContext(ctx, query, accountID, startTime, endTime)
4447+
if err != nil {
4448+
return nil, err
4449+
}
4450+
defer rows.Close()
4451+
var result []service.RecentAccountUser
4452+
for rows.Next() {
4453+
var u service.RecentAccountUser
4454+
if err := rows.Scan(&u.UserID, &u.Email, &u.Requests, &u.AccountCost, &u.UserCost, &u.LastUsedAt); err != nil {
4455+
return nil, err
4456+
}
4457+
result = append(result, u)
4458+
}
4459+
return result, rows.Err()
4460+
}

backend/internal/server/api_contract_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2325,6 +2325,14 @@ func (r *stubUsageLogRepo) GetAllGroupUsageSummary(ctx context.Context, todaySta
23252325
return nil, errors.New("not implemented")
23262326
}
23272327

2328+
func (r *stubUsageLogRepo) GetRecentAccountUsers(ctx context.Context, accountID int64, minutes int) ([]service.RecentAccountUser, error) {
2329+
return nil, nil
2330+
}
2331+
2332+
func (r *stubUsageLogRepo) GetAccountUsersByTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]service.RecentAccountUser, error) {
2333+
return nil, nil
2334+
}
2335+
23282336
type stubSettingRepo struct {
23292337
all map[string]string
23302338
}

backend/internal/server/routes/admin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
278278
accounts.GET("/:id/usage", h.Admin.Account.GetUsage)
279279
accounts.GET("/:id/today-stats", h.Admin.Account.GetTodayStats)
280280
accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats)
281+
accounts.GET("/:id/recent-users", h.Admin.Account.GetRecentUsers)
281282
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
282283
accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota)
283284
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)

backend/internal/service/account_usage_service.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ type UsageLogRepository interface {
7777
GetAccountStatsAggregated(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
7878
GetModelStatsAggregated(ctx context.Context, modelName string, startTime, endTime time.Time) (*usagestats.UsageStats, error)
7979
GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) ([]map[string]any, error)
80+
81+
// GetRecentAccountUsers returns users who used the account in the last N minutes
82+
GetRecentAccountUsers(ctx context.Context, accountID int64, minutes int) ([]RecentAccountUser, error)
83+
// GetAccountUsersByTimeRange returns users who used the account within the selected time range.
84+
GetAccountUsersByTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]RecentAccountUser, error)
85+
}
86+
87+
// RecentAccountUser represents a user who recently used an account
88+
type RecentAccountUser struct {
89+
UserID int64 `json:"user_id"`
90+
Email string `json:"email"`
91+
Requests int64 `json:"requests"`
92+
AccountCost float64 `json:"account_cost"`
93+
UserCost float64 `json:"user_cost"`
94+
LastUsedAt time.Time `json:"last_used_at"`
8095
}
8196

8297
type accountWindowStatsBatchReader interface {
@@ -1335,3 +1350,13 @@ func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64
13351350
func (s *AccountUsageService) GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error) {
13361351
return s.usageLogRepo.GetAccountWindowStats(ctx, accountID, startTime)
13371352
}
1353+
1354+
// GetRecentAccountUsers returns users who used the account in the last N minutes
1355+
func (s *AccountUsageService) GetRecentAccountUsers(ctx context.Context, accountID int64, minutes int) ([]RecentAccountUser, error) {
1356+
return s.usageLogRepo.GetRecentAccountUsers(ctx, accountID, minutes)
1357+
}
1358+
1359+
// GetAccountUsersByTimeRange returns users who used the account within the selected time range.
1360+
func (s *AccountUsageService) GetAccountUsersByTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]RecentAccountUser, error) {
1361+
return s.usageLogRepo.GetAccountUsersByTimeRange(ctx, accountID, startTime, endTime)
1362+
}

frontend/src/api/admin/accounts.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,32 @@ export async function setPrivacy(id: number): Promise<Account> {
630630
return data
631631
}
632632

633+
/**
634+
* Recent account user type
635+
*/
636+
export interface RecentAccountUser {
637+
user_id: number
638+
email: string
639+
requests: number
640+
account_cost: number
641+
user_cost: number
642+
last_used_at: string
643+
}
644+
645+
/**
646+
* Get recent users of an account (last 5 minutes)
647+
* @param id - Account ID
648+
* @returns List of recent users
649+
*/
650+
export async function getRecentUsers(id: number, params?: {
651+
start_date?: string
652+
end_date?: string
653+
timezone?: string
654+
}): Promise<{ users: RecentAccountUser[] }> {
655+
const { data } = await apiClient.get<{ users: RecentAccountUser[] }>(`/admin/accounts/${id}/recent-users`, { params })
656+
return data
657+
}
658+
633659
export const accountsAPI = {
634660
list,
635661
listWithEtag,
@@ -666,7 +692,8 @@ export const accountsAPI = {
666692
getAntigravityDefaultModelMapping,
667693
batchClearError,
668694
batchRefresh,
669-
setPrivacy
695+
setPrivacy,
696+
getRecentUsers
670697
}
671698

672699
export default accountsAPI

frontend/src/components/layout/AppSidebar.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@ const adminNavItems = computed((): NavItem[] => {
717717
},
718718
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
719719
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
720+
{ path: '/admin/account-stats', label: t('nav.accountStats'), icon: ChartIcon },
720721
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
721722
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
722723
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },

frontend/src/i18n/locales/en.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ export default {
358358
availableChannels: 'Available Channels',
359359
subscriptions: 'Subscriptions',
360360
accounts: 'Accounts',
361+
accountStats: 'Account Stats',
361362
proxies: 'Proxies',
362363
redeemCodes: 'Redeem Codes',
363364
ops: 'Ops',
@@ -3574,6 +3575,51 @@ export default {
35743575
usageError: 'Fetch Error'
35753576
},
35763577

3578+
// Account Stats
3579+
accountStats: {
3580+
title: 'Account Statistics',
3581+
description: 'View usage statistics by account',
3582+
timeRange: 'Time Range',
3583+
autoRefresh: 'Auto Refresh',
3584+
enableAutoRefresh: 'Enable Auto Refresh',
3585+
refreshInterval5s: '5 seconds',
3586+
refreshInterval10s: '10 seconds',
3587+
refreshInterval15s: '15 seconds',
3588+
refreshInterval30s: '30 seconds',
3589+
autoRefreshCountdown: 'Auto refresh: {seconds}s',
3590+
account: 'Account',
3591+
platform: 'Platform',
3592+
capacity: 'Concurrency / Capacity',
3593+
requests: 'Requests',
3594+
tokens: 'Tokens',
3595+
cost: 'Cost',
3596+
accountBilling: 'Account Billing',
3597+
userCharge: 'User Charge',
3598+
actions: 'Actions',
3599+
viewDetail: 'View Detail',
3600+
noAccounts: 'No account data',
3601+
rangeUsers: 'Active Users in Current Page Time Range',
3602+
noRangeUsers: 'No active users in this time range',
3603+
recentUsers: 'Recent Active Users (5 min)',
3604+
noRecentUsers: 'No active users',
3605+
user: 'User',
3606+
email: 'Email',
3607+
requestCount: 'Requests',
3608+
currentRequests: 'Current Requests',
3609+
lastUsedAt: 'Last Used',
3610+
activeNow: 'Active',
3611+
accountDetail: 'Account Detail',
3612+
usageSummary: 'Usage Summary',
3613+
totalRequests: 'Total Requests',
3614+
totalTokens: 'Total Tokens',
3615+
totalCost: 'Total Cost',
3616+
inputTokens: 'Input Tokens',
3617+
outputTokens: 'Output Tokens',
3618+
allPlatforms: 'All Platforms',
3619+
allGroups: 'All Groups',
3620+
ungroupedGroup: 'Ungrouped',
3621+
},
3622+
35773623
// Scheduled Tests
35783624
scheduledTests: {
35793625
title: 'Scheduled Tests',

frontend/src/i18n/locales/zh.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ export default {
358358
availableChannels: '可用渠道',
359359
subscriptions: '订阅管理',
360360
accounts: '账号管理',
361+
accountStats: '账号统计',
361362
proxies: 'IP管理',
362363
redeemCodes: '兑换码',
363364
ops: '运维监控',
@@ -3669,6 +3670,51 @@ export default {
36693670
}
36703671
},
36713672

3673+
// Account Stats
3674+
accountStats: {
3675+
title: '账号统计',
3676+
description: '以账号为维度查看使用统计',
3677+
timeRange: '时间范围',
3678+
autoRefresh: '自动刷新',
3679+
enableAutoRefresh: '启用自动刷新',
3680+
refreshInterval5s: '5 秒',
3681+
refreshInterval10s: '10 秒',
3682+
refreshInterval15s: '15 秒',
3683+
refreshInterval30s: '30 秒',
3684+
autoRefreshCountdown: '自动刷新:{seconds}s',
3685+
account: '账号',
3686+
platform: '平台',
3687+
capacity: '并发/容量',
3688+
requests: '请求数',
3689+
tokens: 'Token',
3690+
cost: '费用',
3691+
accountBilling: '账号计费',
3692+
userCharge: '用户扣费',
3693+
actions: '操作',
3694+
viewDetail: '查看详情',
3695+
noAccounts: '暂无账号数据',
3696+
rangeUsers: '按照本页时间范围筛选的活跃用户',
3697+
noRangeUsers: '本页时间范围内暂无活跃用户',
3698+
recentUsers: '最近活跃用户(5分钟内)',
3699+
noRecentUsers: '暂无活跃用户',
3700+
user: '用户',
3701+
email: '邮箱',
3702+
requestCount: '请求数',
3703+
currentRequests: '当前请求',
3704+
lastUsedAt: '最后使用',
3705+
activeNow: '活跃中',
3706+
accountDetail: '账号详情',
3707+
usageSummary: '使用汇总',
3708+
totalRequests: '总请求数',
3709+
totalTokens: '总 Token',
3710+
totalCost: '总费用',
3711+
inputTokens: '输入 Token',
3712+
outputTokens: '输出 Token',
3713+
allPlatforms: '全部平台',
3714+
allGroups: '全部分组',
3715+
ungroupedGroup: '未分组',
3716+
},
3717+
36723718
// Scheduled Tests
36733719
scheduledTests: {
36743720
title: '定时测试',

frontend/src/router/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,18 @@ const routes: RouteRecordRaw[] = [
445445
descriptionKey: 'admin.accounts.description'
446446
}
447447
},
448+
{
449+
path: '/admin/account-stats',
450+
name: 'AdminAccountStats',
451+
component: () => import('@/views/admin/AccountStatsView.vue'),
452+
meta: {
453+
requiresAuth: true,
454+
requiresAdmin: true,
455+
title: 'Account Statistics',
456+
titleKey: 'admin.accountStats.title',
457+
descriptionKey: 'admin.accountStats.description'
458+
}
459+
},
448460
{
449461
path: '/admin/announcements',
450462
name: 'AdminAnnouncements',

0 commit comments

Comments
 (0)