mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(admin): gate scheduler score calculation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user