fix(scheduler): 修复 OpenAI 高级调度器审计发现的正确性与性能问题

针对 #3692 合并后审计发现的问题集中修复:

- previous_response_id 剥离条件改为按 call_id 全覆盖校验,
  部分可重建的工具续链不再被误剥离(不受开关门控的行为回归)
- 粘性加权回退路径补分组归属校验并清理失效绑定,杜绝跨分组账号泄漏
- 账号列表页:无 OpenAI 账号时跳过分数计算、过滤池限定 openai 平台、
  负载批查合并为账号并集一次查询,消除全表扫描与 Redis N+1
- 订阅优先模式下常规池不可用时回退订阅池等待计划,
  busy-but-waitable 的订阅账号不再导致请求硬失败
- TopK/权重 DB 覆盖显式受总开关门控,与兄弟子开关语义一致
- 前端未分组 OpenAI 账号回退展示基础分,不再显示 "-"
- ListAllWithFilters 等能力正式进入 AccountRepository/AdminService 接口,
  移除匿名接口断言与静默降级;负载批查失败补 warn 日志
- SelectAccountWithSchedulerForCapability 增加显式 previousResponseCanMove
  参数,移除 "previous_response_can_move" 魔法字符串哨兵
- 设置写入路径补"基础权重不得全为零"聚合校验;
  运行时设置批量读取失败的降级路径覆盖全部键并留痕
This commit is contained in:
shaw
2026-07-06 11:43:16 +08:00
parent 759332aa92
commit 0fd2e9216d
22 changed files with 798 additions and 96 deletions
+10 -3
View File
@@ -679,14 +679,21 @@ const formatStickySchedulerScore = (score: AccountSchedulerGroupScore): string =
}
const getSchedulerScoreRows = (account: Account): AccountSchedulerGroupScore[] => {
if (!Array.isArray(account.scheduler_scores)) return []
return account.scheduler_scores.filter(score => score.group_id != null)
const groupRows = Array.isArray(account.scheduler_scores)
? account.scheduler_scores.filter(score => score.group_id != null)
: []
if (groupRows.length) return groupRows
// 未分组账号没有分组维度分数,回退展示后端返回的基础分
if (account.scheduler_score) {
return [{ group_id: null, ...account.scheduler_score }]
}
return []
}
const formatSchedulerScoreGroup = (score: AccountSchedulerGroupScore): string => {
if ('group_name' in score && score.group_name) return score.group_name
if ('group_id' in score && score.group_id != null) return `#${score.group_id}`
return '-'
return t('admin.accounts.schedulerScore.ungrouped')
}
const loadSavedColumns = () => {
@@ -0,0 +1,224 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AccountsView from '../AccountsView.vue'
const {
listAccounts,
listWithEtag,
getBatchTodayStats,
getAllProxies,
getAllGroups
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getBatchTodayStats: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
list: listAccounts,
listWithEtag,
getBatchTodayStats,
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
toggleSchedulable: vi.fn()
},
proxies: {
getAll: getAllProxies
},
groups: {
getAll: getAllGroups
}
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
})
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
token: 'test-token'
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
// Render the scheduler-score cell slot for every row so the fallback logic is observable.
const DataTableStub = {
props: ['columns', 'data'],
template: `
<div data-test="data-table">
<div v-for="row in data" :key="row.id" :data-test="'scheduler-score-' + row.id">
<slot name="cell-scheduler_score" :row="row" />
</div>
</div>
`
}
function mountView() {
return mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
DataTable: DataTableStub,
HelpTooltip: true,
Pagination: true,
ConfirmDialog: true,
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
AccountTableFilters: { template: '<div></div>' },
AccountBulkActionsBar: true,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: true,
AccountStatsModal: true,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: true,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: true,
AccountUsageCell: true,
Icon: true
}
}
})
}
const baseAccount = {
platform: 'openai',
type: 'apikey',
status: 'active',
schedulable: true,
concurrency: 1,
priority: 0,
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
}
describe('admin AccountsView scheduler score column', () => {
beforeEach(() => {
localStorage.clear()
listAccounts.mockReset()
listWithEtag.mockReset()
getBatchTodayStats.mockReset()
getAllProxies.mockReset()
getAllGroups.mockReset()
listAccounts.mockResolvedValue({
items: [
{
...baseAccount,
id: 1,
name: 'ungrouped-openai',
// 未分组账号:后端只返回基础分(scheduler_score),无分组维度分数
scheduler_score: {
base_score: 1.234567,
sticky_score: 0,
sticky_weighted_enabled: false
}
},
{
...baseAccount,
id: 2,
name: 'grouped-openai',
scheduler_score: {
base_score: 2,
sticky_score: 3,
sticky_weighted_enabled: true
},
scheduler_scores: [
{
group_id: 5,
group_name: 'group-five',
base_score: 2,
sticky_score: 3,
sticky_weighted_enabled: true
}
]
},
{
...baseAccount,
id: 3,
name: 'no-score',
platform: 'anthropic'
}
],
total: 3,
page: 1,
page_size: 20,
pages: 1
})
listWithEtag.mockResolvedValue({
notModified: true,
etag: null,
data: null
})
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
})
it('falls back to the base score for ungrouped accounts instead of showing a dash', async () => {
const wrapper = mountView()
await flushPromises()
const ungroupedCell = wrapper.find('[data-test="scheduler-score-1"]')
expect(ungroupedCell.exists()).toBe(true)
expect(ungroupedCell.text()).toContain('1.234567')
expect(ungroupedCell.text()).toContain('admin.accounts.schedulerScore.ungrouped')
expect(ungroupedCell.text()).not.toBe('-')
})
it('renders per-group scores for grouped accounts', async () => {
const wrapper = mountView()
await flushPromises()
const groupedCell = wrapper.find('[data-test="scheduler-score-2"]')
expect(groupedCell.exists()).toBe(true)
expect(groupedCell.text()).toContain('group-five')
expect(groupedCell.text()).toContain('2')
})
it('still shows a dash when no scheduler score is available', async () => {
const wrapper = mountView()
await flushPromises()
const emptyCell = wrapper.find('[data-test="scheduler-score-3"]')
expect(emptyCell.exists()).toBe(true)
expect(emptyCell.text()).toBe('-')
})
})