From 6ae5fc31b3e21f542f659296a567c15fe45c0065 Mon Sep 17 00:00:00 2001 From: jjaw Date: Wed, 8 Jul 2026 06:58:35 +0800 Subject: [PATCH] fix(admin): gate scheduler score calculation --- .../internal/handler/admin/account_handler.go | 6 ++- .../admin/account_handler_list_test.go | 43 +++++++++++++++++-- .../handler/admin/admin_service_stub_test.go | 4 ++ frontend/src/api/admin/accounts.ts | 2 + frontend/src/views/admin/AccountsView.vue | 34 ++++++++++++++- .../AccountsView.schedulerScore.spec.ts | 28 ++++++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 8c91245fbf..a4b0773999 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -485,6 +485,8 @@ func (h *AccountHandler) List(c *gin.Context) { search = search[:100] } lite := parseBoolQueryWithDefault(c.Query("lite"), false) + // 调度分需要跨候选池批量打分并读取负载,默认列表不计算;只有前端列可见时才显式开启。 + includeSchedulerScore := parseBoolQueryWithDefault(c.Query("include_scheduler_score"), false) var groupID int64 if groupIDStr := c.Query("group"); groupIDStr != "" { @@ -520,7 +522,7 @@ func (h *AccountHandler) List(c *gin.Context) { var windowCosts map[int64]float64 var activeSessions map[int64]int var rpmCounts map[int64]int - // 仅当前页存在 OpenAI 账号时才计算调度分数,避免为空结果付出池查询开销。 + // 双重门控:用户要看该列,且当前页确实有 OpenAI 账号,才进入昂贵的候选池打分路径。 var schedulerScores map[int64]*AccountSchedulerScore var schedulerGroupScores map[int64][]AccountSchedulerGroupScore pageHasOpenAIAccounts := false @@ -530,7 +532,7 @@ func (h *AccountHandler) List(c *gin.Context) { break } } - if pageHasOpenAIAccounts { + if includeSchedulerScore && pageHasOpenAIAccounts { schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode) schedulerScores, schedulerGroupScores = h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool) } diff --git a/backend/internal/handler/admin/account_handler_list_test.go b/backend/internal/handler/admin/account_handler_list_test.go index 29e36ad865..4b1bd7224c 100644 --- a/backend/internal/handler/admin/account_handler_list_test.go +++ b/backend/internal/handler/admin/account_handler_list_test.go @@ -92,7 +92,7 @@ func TestAccountHandlerListReturnsSchedulerScoresPerGroup(t *testing.T) { } rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai&include_scheduler_score=1", nil) router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) @@ -147,6 +147,43 @@ func TestAccountHandlerListReturnsSchedulerScoresPerGroup(t *testing.T) { require.Greater(t, high.SchedulerScores[0].BaseScore, low.SchedulerScores[0].BaseScore) } +func TestAccountHandlerListSkipsSchedulerScoresByDefault(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + adminSvc.accounts = []service.Account{ + { + ID: 110, + Name: "openai-account", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 1, + CreatedAt: now, + UpdatedAt: now, + }, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Zero(t, adminSvc.schedulerScoreFilterCalls) + require.Zero(t, adminSvc.openAISchedulerScorePoolCalls) + + var payload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + require.NotContains(t, payload.Data.Items[0], "scheduler_score") + require.NotContains(t, payload.Data.Items[0], "scheduler_scores") +} + func TestAccountHandlerListKeepsSchedulerScoreScopedToFilter(t *testing.T) { router, adminSvc := setupAccountListRouter() now := time.Now().UTC() @@ -188,7 +225,7 @@ func TestAccountHandlerListKeepsSchedulerScoreScopedToFilter(t *testing.T) { adminSvc.openAISchedulerScorePoolAccounts = []service.Account{visibleAccount, hiddenGroupPeer} rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai&include_scheduler_score=1", nil) router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) @@ -246,7 +283,7 @@ func TestAccountHandlerListSchedulerScoreIgnoresPagination(t *testing.T) { adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenFilterPeer} rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai&include_scheduler_score=1", nil) router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 187925e33a..7a7cbb473e 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -16,6 +16,8 @@ type stubAdminService struct { accounts []service.Account accountSchedulerScoreFilterAccounts []service.Account openAISchedulerScorePoolAccounts []service.Account + schedulerScoreFilterCalls int + openAISchedulerScorePoolCalls int proxies []service.Proxy proxyCounts []service.ProxyWithAccountCount redeems []service.RedeemCode @@ -351,6 +353,7 @@ func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, } func (s *stubAdminService) ListAccountsForSchedulerScoreFilter(_ context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) { + s.schedulerScoreFilterCalls++ if s.accountSchedulerScoreFilterAccounts != nil { return s.accountSchedulerScoreFilterAccounts, nil } @@ -358,6 +361,7 @@ func (s *stubAdminService) ListAccountsForSchedulerScoreFilter(_ context.Context } func (s *stubAdminService) ListOpenAISchedulableAccountsForSchedulerScore(_ context.Context, groupID *int64) ([]service.Account, error) { + s.openAISchedulerScorePoolCalls++ accounts := s.openAISchedulerScorePoolAccounts if accounts == nil { accounts = s.accounts diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 7cdb092820..d0ec12c364 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -41,6 +41,7 @@ export async function list( search?: string privacy_mode?: string lite?: string + include_scheduler_score?: string sort_by?: string sort_order?: 'asc' | 'desc' }, @@ -76,6 +77,7 @@ export async function listWithEtag( search?: string privacy_mode?: string lite?: string + include_scheduler_score?: string sort_by?: string sort_order?: 'asc' | 'desc' }, diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index b4e1630a85..c1fd0aa2d4 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -547,8 +547,11 @@ const exportingData = ref(false) const showAccountToolsDropdown = ref(false) const accountToolsDropdownRef = ref(null) const hiddenColumns = reactive>(new Set()) -const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier'] +const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'scheduler_score', 'rate_multiplier'] const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' +// One-time migration: hide scheduler score for existing admins too, because showing it opt-ins to heavy backend scoring. +const HIDDEN_COLUMNS_VERSION_KEY = 'account-hidden-columns-version' +const HIDDEN_COLUMNS_CURRENT_VERSION = 'scheduler-score-hidden-by-default' // Sorting settings const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort' @@ -704,10 +707,17 @@ const loadSavedColumns = () => { parsed.forEach(key => { hiddenColumns.add(key) }) + // Older saved column layouts may have scheduler_score visible; migrate them to the new safe default once. + if (localStorage.getItem(HIDDEN_COLUMNS_VERSION_KEY) !== HIDDEN_COLUMNS_CURRENT_VERSION) { + hiddenColumns.add('scheduler_score') + localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns])) + localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION) + } } else { DEFAULT_HIDDEN_COLUMNS.forEach(key => { hiddenColumns.add(key) }) + localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION) } } catch (e) { console.error('Failed to load saved columns:', e) @@ -720,6 +730,7 @@ const loadSavedColumns = () => { const saveColumnsToStorage = () => { try { localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns])) + localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION) } catch (e) { console.error('Failed to save columns:', e) } @@ -792,9 +803,22 @@ const toggleColumn = (key: string) => { console.error('Failed to load account today stats after showing column:', error) }) } + if (key === 'scheduler_score') { + // The server only returns scheduler scores when this column is visible, so reload the current page immediately. + syncAccountListDerivedParams() + load().catch((error) => { + console.error('Failed to reload accounts after toggling scheduler score column:', error) + }) + } } const isColumnVisible = (key: string) => !hiddenColumns.has(key) +const shouldIncludeSchedulerScore = () => isColumnVisible('scheduler_score') +const syncAccountListDerivedParams = () => { + // Keep every load path, including auto-refresh and sorting, aligned with the current column visibility. + const requestParams = params as any + requestParams.include_scheduler_score = shouldIncludeSchedulerScore() ? '1' : '0' +} const { items: accounts, @@ -815,6 +839,7 @@ const { privacy_mode: '', group: '', search: '', + include_scheduler_score: shouldIncludeSchedulerScore() ? '1' : '0', sort_by: sortState.sort_by, sort_order: sortState.sort_order } @@ -859,6 +884,7 @@ const isFirstLoad = ref(true) const load = async () => { const requestParams = params as any + syncAccountListDerivedParams() hasPendingListSync.value = false resetAutoRefreshCache() pendingTodayStatsRefresh.value = false @@ -874,6 +900,7 @@ const load = async () => { } const reload = async () => { + syncAccountListDerivedParams() hasPendingListSync.value = false resetAutoRefreshCache() pendingTodayStatsRefresh.value = false @@ -882,6 +909,7 @@ const reload = async () => { } const debouncedReload = () => { + syncAccountListDerivedParams() hasPendingListSync.value = false resetAutoRefreshCache() pendingTodayStatsRefresh.value = true @@ -889,6 +917,7 @@ const debouncedReload = () => { } const handlePageChange = (page: number) => { + syncAccountListDerivedParams() hasPendingListSync.value = false resetAutoRefreshCache() pendingTodayStatsRefresh.value = true @@ -896,6 +925,7 @@ const handlePageChange = (page: number) => { } const handlePageSizeChange = (size: number) => { + syncAccountListDerivedParams() hasPendingListSync.value = false resetAutoRefreshCache() pendingTodayStatsRefresh.value = true @@ -908,6 +938,7 @@ const handleSort = (key: string, order: AccountSortOrder) => { const requestParams = params as any requestParams.sort_by = key requestParams.sort_order = order + syncAccountListDerivedParams() pagination.page = 1 hasPendingListSync.value = false resetAutoRefreshCache() @@ -1007,6 +1038,7 @@ const mergeAccountsIncrementally = (nextRows: Account[]) => { const refreshAccountsIncrementally = async () => { if (autoRefreshFetching.value) return + syncAccountListDerivedParams() autoRefreshFetching.value = true try { const result = await adminAPI.accounts.listWithEtag( diff --git a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts index 0865a6ec91..e74af50182 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts @@ -196,6 +196,10 @@ describe('admin AccountsView scheduler score column', () => { const wrapper = mountView() await flushPromises() + expect(listAccounts.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ + include_scheduler_score: '0' + })) + const ungroupedCell = wrapper.find('[data-test="scheduler-score-1"]') expect(ungroupedCell.exists()).toBe(true) expect(ungroupedCell.text()).toContain('1.234567') @@ -213,6 +217,30 @@ describe('admin AccountsView scheduler score column', () => { expect(groupedCell.text()).toContain('2') }) + it('keeps scheduler score hidden for old saved column settings until the admin opts in again', async () => { + localStorage.setItem('account-hidden-columns', JSON.stringify(['today_stats'])) + + mountView() + await flushPromises() + + expect(listAccounts.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ + include_scheduler_score: '0' + })) + expect(JSON.parse(localStorage.getItem('account-hidden-columns') || '[]')).toContain('scheduler_score') + }) + + it('requests scheduler scores when the migrated column settings explicitly show the column', async () => { + localStorage.setItem('account-hidden-columns', JSON.stringify(['today_stats'])) + localStorage.setItem('account-hidden-columns-version', 'scheduler-score-hidden-by-default') + + mountView() + await flushPromises() + + expect(listAccounts.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ + include_scheduler_score: '1' + })) + }) + it('still shows a dash when no scheduler score is available', async () => { const wrapper = mountView() await flushPromises()