Merge pull request #3811 from jianjianai/hotfix/admin-scheduler-score-opt-in

fix(admin): 管理员账号列表默认关闭调度权值计算以降低负载
This commit is contained in:
Wesley Liddick
2026-07-08 10:22:10 +08:00
committed by GitHub
6 changed files with 111 additions and 6 deletions
@@ -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)
}
@@ -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)
@@ -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
+2
View File
@@ -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'
},
+33 -1
View File
@@ -547,8 +547,11 @@ const exportingData = ref(false)
const showAccountToolsDropdown = ref(false)
const accountToolsDropdownRef = ref<HTMLElement | null>(null)
const hiddenColumns = reactive<Set<string>>(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(
@@ -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()