From 67c050629006e8260eca16fc231ca508ac9d9312 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 16 Mar 2026 13:39:50 +0800 Subject: [PATCH 001/175] fix(billing): add window expiration check to Redis rate limit Lua script The updateRateLimitUsageScript Lua script previously performed unconditional HINCRBYFLOAT on all usage counters without checking whether the rate limit window had expired. This caused usage to accumulate across window boundaries in Redis while the DB correctly reset on expiration, leading to incorrect 429 rate limiting that could persist for up to 24 hours. The Lua script now checks each window timestamp before incrementing: - If the window has expired, usage is reset to the current cost and the window timestamp is updated (matching DB-side semantics) - If the window is still valid, usage is accumulated normally This also resolves the async race condition where stale HINCRBYFLOAT tasks from the worker queue could pollute a freshly rebuilt cache after invalidation, since the script now self-corrects expired windows. Closes #1049 --- backend/internal/repository/billing_cache.go | 48 +++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/backend/internal/repository/billing_cache.go b/backend/internal/repository/billing_cache.go index 4fbdae14..6922b4c8 100644 --- a/backend/internal/repository/billing_cache.go +++ b/backend/internal/repository/billing_cache.go @@ -20,6 +20,11 @@ const ( billingCacheTTL = 5 * time.Minute billingCacheJitter = 30 * time.Second rateLimitCacheTTL = 7 * 24 * time.Hour // 7 days matches the longest window + + // Rate limit window durations — must match service.RateLimitWindow* constants. + rateLimitWindow5h = 5 * time.Hour + rateLimitWindow1d = 24 * time.Hour + rateLimitWindow7d = 7 * 24 * time.Hour ) // jitteredTTL 返回带随机抖动的 TTL,防止缓存雪崩 @@ -90,17 +95,40 @@ var ( return 1 `) - // updateRateLimitUsageScript atomically increments all three rate limit usage counters. - // Returns 0 if the key doesn't exist (cache miss), 1 on success. + // updateRateLimitUsageScript atomically increments all three rate limit usage counters + // with window expiration checking. If a window has expired, its usage is reset to cost + // (instead of accumulated) and the window timestamp is updated, matching the DB-side + // IncrementRateLimitUsage semantics. + // + // ARGV: [1]=cost, [2]=ttl_seconds, [3]=now_unix, [4]=window_5h_seconds, [5]=window_1d_seconds, [6]=window_7d_seconds updateRateLimitUsageScript = redis.NewScript(` local exists = redis.call('EXISTS', KEYS[1]) if exists == 0 then return 0 end local cost = tonumber(ARGV[1]) - redis.call('HINCRBYFLOAT', KEYS[1], 'usage_5h', cost) - redis.call('HINCRBYFLOAT', KEYS[1], 'usage_1d', cost) - redis.call('HINCRBYFLOAT', KEYS[1], 'usage_7d', cost) + local now = tonumber(ARGV[3]) + local win5h = tonumber(ARGV[4]) + local win1d = tonumber(ARGV[5]) + local win7d = tonumber(ARGV[6]) + + -- Helper: check if window is expired and update usage + window accordingly + -- Returns nothing, modifies the hash in-place. + local function update_window(usage_field, window_field, window_duration) + local w = tonumber(redis.call('HGET', KEYS[1], window_field) or 0) + if w == 0 or (now - w) >= window_duration then + -- Window expired or never started: reset usage to cost, start new window + redis.call('HSET', KEYS[1], usage_field, tostring(cost)) + redis.call('HSET', KEYS[1], window_field, tostring(now)) + else + -- Window still valid: accumulate + redis.call('HINCRBYFLOAT', KEYS[1], usage_field, cost) + end + end + + update_window('usage_5h', 'window_5h', win5h) + update_window('usage_1d', 'window_1d', win1d) + update_window('usage_7d', 'window_7d', win7d) redis.call('EXPIRE', KEYS[1], ARGV[2]) return 1 `) @@ -280,7 +308,15 @@ func (c *billingCache) SetAPIKeyRateLimit(ctx context.Context, keyID int64, data func (c *billingCache) UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error { key := billingRateLimitKey(keyID) - _, err := updateRateLimitUsageScript.Run(ctx, c.rdb, []string{key}, cost, int(rateLimitCacheTTL.Seconds())).Result() + now := time.Now().Unix() + _, err := updateRateLimitUsageScript.Run(ctx, c.rdb, []string{key}, + cost, + int(rateLimitCacheTTL.Seconds()), + now, + int(rateLimitWindow5h.Seconds()), + int(rateLimitWindow1d.Seconds()), + int(rateLimitWindow7d.Seconds()), + ).Result() if err != nil && !errors.Is(err, redis.Nil) { log.Printf("Warning: update rate limit usage cache failed for api key %d: %v", keyID, err) return err From 71f72e167eb00c68ce46391f9d98f24ccfde9c0f Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 16 Mar 2026 15:47:32 +0800 Subject: [PATCH 002/175] chore(antigravity): bump default User-Agent version to 1.20.5 --- backend/internal/pkg/antigravity/oauth.go | 4 ++-- backend/internal/pkg/antigravity/oauth_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index 5bda31ac..8a8bed92 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -49,8 +49,8 @@ const ( antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com" ) -// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.4 -var defaultUserAgentVersion = "1.20.4" +// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5 +var defaultUserAgentVersion = "1.20.5" // defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置 var defaultClientSecret = "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" diff --git a/backend/internal/pkg/antigravity/oauth_test.go b/backend/internal/pkg/antigravity/oauth_test.go index f4630b09..3a093fe6 100644 --- a/backend/internal/pkg/antigravity/oauth_test.go +++ b/backend/internal/pkg/antigravity/oauth_test.go @@ -690,7 +690,7 @@ func TestConstants_值正确(t *testing.T) { if RedirectURI != "http://localhost:8085/callback" { t.Errorf("RedirectURI 不匹配: got %s", RedirectURI) } - if GetUserAgent() != "antigravity/1.20.4 windows/amd64" { + if GetUserAgent() != "antigravity/1.20.5 windows/amd64" { t.Errorf("UserAgent 不匹配: got %s", GetUserAgent()) } if SessionTTL != 30*time.Minute { From afd72abc6ed765cda52e2c6e5876df5d28e5e5ee Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:22:31 +0800 Subject: [PATCH 003/175] fix: allow empty extra payload to clear account quota limits UpdateAccount previously required len(input.Extra) > 0, causing explicit empty payloads (extra:{}) to be silently skipped. Change condition to input.Extra != nil so clearing quota keys actually persists. --- backend/internal/service/admin_service.go | 4 ++- .../service/admin_service_overages_test.go | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ea76e171..5eeac183 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1530,7 +1530,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if len(input.Credentials) > 0 { account.Credentials = input.Credentials } - if len(input.Extra) > 0 { + // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 + // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 + if input.Extra != nil { // 保留配额用量字段,防止编辑账号时意外重置 for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { if v, ok := account.Extra[key]; ok { diff --git a/backend/internal/service/admin_service_overages_test.go b/backend/internal/service/admin_service_overages_test.go index 779b08b9..d6380f4d 100644 --- a/backend/internal/service/admin_service_overages_test.go +++ b/backend/internal/service/admin_service_overages_test.go @@ -121,3 +121,35 @@ func TestUpdateAccount_EnableOveragesClearsModelRateLimitsBeforePersist(t *testi _, exists := repo.account.Extra[modelRateLimitsKey] require.False(t, exists, "开启 overages 时应在持久化前清掉旧模型限流") } + +func TestUpdateAccount_EmptyExtraPayloadCanClearQuotaLimits(t *testing.T) { + accountID := int64(103) + repo := &updateAccountOveragesRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + Extra: map[string]any{ + "quota_limit": 100.0, + "quota_daily_limit": 10.0, + "quota_weekly_limit": 40.0, + }, + }, + } + + svc := &adminServiceImpl{accountRepo: repo} + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + // 显式空对象:语义是“清空 extra 中的可配置键”(例如关闭配额限制) + Extra: map[string]any{}, + }) + + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, 1, repo.updateCalls) + require.NotNil(t, repo.account.Extra) + require.NotContains(t, repo.account.Extra, "quota_limit") + require.NotContains(t, repo.account.Extra, "quota_daily_limit") + require.NotContains(t, repo.account.Extra, "quota_weekly_limit") + require.Len(t, repo.account.Extra, 0) +} From fa782e70a43916e374bd53444208fdf52f7bca8d Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:22:42 +0800 Subject: [PATCH 004/175] fix: always attach OpenAI 5h/7d window stats regardless of zero values Removes hasMeaningfulWindowStats guard so the /usage endpoint consistently returns WindowStats for both time windows. The frontend now controls zero-value display filtering at the component level. --- .../internal/service/account_usage_service.go | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index f117abfd..959c1182 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -446,23 +446,17 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou } if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-5*time.Hour)); err == nil { - windowStats := windowStatsFromAccountStats(stats) - if hasMeaningfulWindowStats(windowStats) { - if usage.FiveHour == nil { - usage.FiveHour = &UsageProgress{Utilization: 0} - } - usage.FiveHour.WindowStats = windowStats + if usage.FiveHour == nil { + usage.FiveHour = &UsageProgress{Utilization: 0} } + usage.FiveHour.WindowStats = windowStatsFromAccountStats(stats) } if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-7*24*time.Hour)); err == nil { - windowStats := windowStatsFromAccountStats(stats) - if hasMeaningfulWindowStats(windowStats) { - if usage.SevenDay == nil { - usage.SevenDay = &UsageProgress{Utilization: 0} - } - usage.SevenDay.WindowStats = windowStats + if usage.SevenDay == nil { + usage.SevenDay = &UsageProgress{Utilization: 0} } + usage.SevenDay.WindowStats = windowStatsFromAccountStats(stats) } return usage, nil @@ -992,13 +986,6 @@ func windowStatsFromAccountStats(stats *usagestats.AccountStats) *WindowStats { } } -func hasMeaningfulWindowStats(stats *WindowStats) bool { - if stats == nil { - return false - } - return stats.Requests > 0 || stats.Tokens > 0 || stats.Cost > 0 || stats.StandardCost > 0 || stats.UserCost > 0 -} - func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now time.Time) *UsageProgress { if len(extra) == 0 { return nil From 8640a62319e1764a9ec29465f568ca203c698620 Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:22:51 +0800 Subject: [PATCH 005/175] refactor: extract formatCompactNumber util and add last_used_at to refresh key - Add formatCompactNumber() for consistent large-number formatting (K/M/B) - Include last_used_at in OpenAI usage refresh key for better change detection - Add .gitattributes eol=lf rules for frontend source files --- .gitattributes | 7 ++++++ .../__tests__/accountUsageRefresh.spec.ts | 24 +++++++++++++++++++ .../__tests__/formatCompactNumber.spec.ts | 22 +++++++++++++++++ frontend/src/utils/accountUsageRefresh.ts | 3 ++- frontend/src/utils/format.ts | 20 ++++++++++++++++ 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/__tests__/formatCompactNumber.spec.ts diff --git a/.gitattributes b/.gitattributes index 3db3b83d..37e3bee2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,13 @@ backend/migrations/*.sql text eol=lf # Go 源代码文件 *.go text eol=lf +# 前端 源代码文件 +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.vue text eol=lf + # Shell 脚本 *.sh text eol=lf diff --git a/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts b/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts index ae13d690..aef73b0f 100644 --- a/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts +++ b/frontend/src/utils/__tests__/accountUsageRefresh.spec.ts @@ -8,6 +8,7 @@ describe('buildOpenAIUsageRefreshKey', () => { platform: 'openai', type: 'oauth', updated_at: '2026-03-07T10:00:00Z', + last_used_at: '2026-03-07T09:59:00Z', extra: { codex_usage_updated_at: '2026-03-07T10:00:00Z', codex_5h_used_percent: 0, @@ -27,12 +28,35 @@ describe('buildOpenAIUsageRefreshKey', () => { expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next)) }) + it('会在 last_used_at 变化时生成不同 key', () => { + const base = { + id: 3, + platform: 'openai', + type: 'oauth', + updated_at: '2026-03-07T10:00:00Z', + last_used_at: '2026-03-07T10:00:00Z', + extra: { + codex_usage_updated_at: '2026-03-07T10:00:00Z', + codex_5h_used_percent: 12, + codex_7d_used_percent: 24 + } + } as any + + const next = { + ...base, + last_used_at: '2026-03-07T10:02:00Z' + } + + expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next)) + }) + it('非 OpenAI OAuth 账号返回空 key', () => { expect(buildOpenAIUsageRefreshKey({ id: 2, platform: 'anthropic', type: 'oauth', updated_at: '2026-03-07T10:00:00Z', + last_used_at: '2026-03-07T10:00:00Z', extra: {} } as any)).toBe('') }) diff --git a/frontend/src/utils/__tests__/formatCompactNumber.spec.ts b/frontend/src/utils/__tests__/formatCompactNumber.spec.ts new file mode 100644 index 00000000..a5a9ed9f --- /dev/null +++ b/frontend/src/utils/__tests__/formatCompactNumber.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { formatCompactNumber } from '../format' + +describe('formatCompactNumber', () => { + it('formats boundary values with K/M/B', () => { + expect(formatCompactNumber(0)).toBe('0') + expect(formatCompactNumber(999)).toBe('999') + expect(formatCompactNumber(1000)).toBe('1.0K') + expect(formatCompactNumber(999999)).toBe('1000.0K') + expect(formatCompactNumber(1000000)).toBe('1.0M') + expect(formatCompactNumber(1000000000)).toBe('1.0B') + }) + + it('supports disabling billion unit (requests style)', () => { + expect(formatCompactNumber(1000000000, { allowBillions: false })).toBe('1000.0M') + }) + + it('returns 0 for nullish input', () => { + expect(formatCompactNumber(null)).toBe('0') + expect(formatCompactNumber(undefined)).toBe('0') + }) +}) diff --git a/frontend/src/utils/accountUsageRefresh.ts b/frontend/src/utils/accountUsageRefresh.ts index 219ac57f..3406c7a5 100644 --- a/frontend/src/utils/accountUsageRefresh.ts +++ b/frontend/src/utils/accountUsageRefresh.ts @@ -5,7 +5,7 @@ const normalizeUsageRefreshValue = (value: unknown): string => { return String(value) } -export const buildOpenAIUsageRefreshKey = (account: Pick): string => { +export const buildOpenAIUsageRefreshKey = (account: Pick): string => { if (account.platform !== 'openai' || account.type !== 'oauth') { return '' } @@ -14,6 +14,7 @@ export const buildOpenAIUsageRefreshKey = (account: Pick= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B` + if (abs >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M` + if (abs >= 1_000) return `${(num / 1_000).toFixed(1)}K` + return num.toString() +} + /** * 格式化倒计时(从现在到目标时间的剩余时间) * @param targetDate 目标日期字符串或 Date 对象 From fbffb08aae4a8bea2db6a6392c9ef30f777a14d0 Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 16:23:00 +0800 Subject: [PATCH 006/175] feat: add today-stats and manual refresh token propagation to usage cells - Pass todayStats/todayStatsLoading to AccountUsageCell for key accounts - Propagate usageManualRefreshToken to force usage reload on explicit refresh - Refresh today stats when toggling usage/today_stats columns visible --- frontend/src/views/admin/AccountsView.vue | 39 +++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index dd342a5b..2ec5b47d 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -203,7 +203,12 @@ @@ -423,12 +442,23 @@ import { adminAPI } from '@/api/admin' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { resolveCodexUsageWindow } from '@/utils/codexUsage' +import { formatCompactNumber } from '@/utils/format' import UsageProgressBar from './UsageProgressBar.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue' -const props = defineProps<{ - account: Account -}>() +const props = withDefaults( + defineProps<{ + account: Account + todayStats?: WindowStats | null + todayStatsLoading?: boolean + manualRefreshToken?: number + }>(), + { + todayStats: null, + todayStatsLoading: false, + manualRefreshToken: 0 + } +) const { t } = useI18n() @@ -490,26 +520,9 @@ const isActiveOpenAIRateLimited = computed(() => { return !Number.isNaN(resetAt) && resetAt > Date.now() }) -const preferFetchedOpenAIUsage = computed(() => { - return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value -}) - const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account)) -const isOpenAICodexSnapshotStale = computed(() => { - if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false - const extra = props.account.extra as Record | undefined - const updatedAtRaw = extra?.codex_usage_updated_at - if (!updatedAtRaw) return true - const updatedAt = Date.parse(String(updatedAtRaw)) - if (Number.isNaN(updatedAt)) return true - return Date.now() - updatedAt >= 10 * 60 * 1000 -}) - const shouldAutoLoadUsageOnMount = computed(() => { - if (props.account.platform === 'openai' && props.account.type === 'oauth') { - return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value - } return shouldFetchUsage.value }) @@ -1006,6 +1019,28 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => { return makeQuotaBar(props.account.quota_used ?? 0, limit) }) +// ===== Key account today stats formatters ===== + +const formatKeyRequests = computed(() => { + if (!props.todayStats) return '' + return formatCompactNumber(props.todayStats.requests, { allowBillions: false }) +}) + +const formatKeyTokens = computed(() => { + if (!props.todayStats) return '' + return formatCompactNumber(props.todayStats.tokens) +}) + +const formatKeyCost = computed(() => { + if (!props.todayStats) return '0.00' + return props.todayStats.cost.toFixed(2) +}) + +const formatKeyUserCost = computed(() => { + if (!props.todayStats || props.todayStats.user_cost == null) return '0.00' + return props.todayStats.user_cost.toFixed(2) +}) + onMounted(() => { if (!shouldAutoLoadUsageOnMount.value) return loadUsage() @@ -1014,10 +1049,21 @@ onMounted(() => { watch(openAIUsageRefreshKey, (nextKey, prevKey) => { if (!prevKey || nextKey === prevKey) return if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return - if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return loadUsage().catch((e) => { console.error('Failed to refresh OpenAI usage:', e) }) }) + +watch( + () => props.manualRefreshToken, + (nextToken, prevToken) => { + if (nextToken === prevToken) return + if (!shouldFetchUsage.value) return + + loadUsage().catch((e) => { + console.error('Failed to refresh usage after manual refresh:', e) + }) + } +) diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index cd5c991f..5ce8bfe0 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -2,7 +2,7 @@
@@ -12,12 +12,13 @@ {{ formatTokens }} - + A ${{ formatAccountCost }} U ${{ formatUserCost }} @@ -56,7 +57,9 @@ From a0b76bd6081ac5cecd50cb43a41e196c7ec80eec Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 19:46:24 +0800 Subject: [PATCH 013/175] feat: implement last 24 hours date range preset and update filters in UsageView --- .../components/admin/usage/UsageFilters.vue | 30 +----- .../src/components/common/DateRangePicker.vue | 14 ++- .../common/__tests__/DateRangePicker.spec.ts | 96 +++++++++++++++++++ frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/views/admin/UsageView.vue | 62 ++++++++++-- .../views/admin/__tests__/UsageView.spec.ts | 22 +++++ 7 files changed, 190 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/common/__tests__/DateRangePicker.spec.ts diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index a632a76e..ee5020e7 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -139,17 +139,6 @@ +
+
+ {{ t('admin.dashboard.timeRange') }}: + +
+
+ {{ t('admin.dashboard.granularity') }}: +
+ +
@@ -813,6 +821,7 @@ let userSearchTimeout: ReturnType | null = null const filters = reactive({ status: 'active', group_id: '', + platform: '', user_id: null as number | null }) @@ -855,6 +864,15 @@ const groupOptions = computed(() => [ ...groups.value.map((g) => ({ value: g.id.toString(), label: g.name })) ]) +const platformFilterOptions = computed(() => [ + { value: '', label: t('admin.subscriptions.allPlatforms') }, + { value: 'anthropic', label: 'Anthropic' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'antigravity', label: 'Antigravity' }, + { value: 'sora', label: 'Sora' } +]) + // Group options for assign (only subscription type groups) const subscriptionGroupOptions = computed(() => groups.value @@ -890,6 +908,7 @@ const loadSubscriptions = async () => { { status: (filters.status as any) || undefined, group_id: filters.group_id ? parseInt(filters.group_id) : undefined, + platform: filters.platform || undefined, user_id: filters.user_id || undefined, sort_by: sortState.sort_by, sort_order: sortState.sort_order From 13e85b3147665865c61bb50138da473388652063 Mon Sep 17 00:00:00 2001 From: Gemini Wen Date: Wed, 18 Mar 2026 09:35:08 +0800 Subject: [PATCH 039/175] fix: update remaining test stubs for List interface signature Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/internal/server/api_contract_test.go | 2 +- backend/internal/server/middleware/api_key_auth_google_test.go | 2 +- backend/internal/server/middleware/api_key_auth_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 309dcf4e..6fe361cf 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1289,7 +1289,7 @@ func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userI func (stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } func (stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 49db5f19..9f9bba13 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -135,7 +135,7 @@ func (f fakeGoogleSubscriptionRepo) ListActiveByUserID(ctx context.Context, user func (f fakeGoogleSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } func (f fakeGoogleSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 22befa2a..a633ffdd 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -646,7 +646,7 @@ func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID in return nil, nil, errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } From 961c30e7c04d80163b80725a82f9631e375b505b Mon Sep 17 00:00:00 2001 From: QTom Date: Tue, 17 Mar 2026 22:09:28 +0800 Subject: [PATCH 040/175] =?UTF-8?q?feat(admin):=20=E5=88=86=E7=BB=84?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=88=97=E8=A1=A8=E6=96=B0=E5=A2=9E=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E5=88=97=E4=B8=8E=E8=B4=A6=E5=8F=B7=E6=95=B0=E5=88=86?= =?UTF-8?q?=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 @@ -