fix: harden routing failover, model testing, and wallet queries

This commit is contained in:
elky
2026-09-09 10:38:25 +08:00
parent a893bd0557
commit 6630856061
14 changed files with 1530 additions and 84 deletions
+26 -11
View File
@@ -501,10 +501,12 @@
</section>
<RoutingFailoverPolicyEditor
:key="draftGeneration"
ref="routingFailoverPolicyEditor"
:model-value="draft.config_json.default_policy"
:disabled="saving"
@update:model-value="draft.config_json.default_policy = { ...draft.config_json.default_policy, ...$event }"
@update:model-value="updateRoutingFailoverPolicy"
@pending-change="routingFailoverPending = $event"
/>
<section class="space-y-4 rounded-lg border border-border/60 p-4">
@@ -890,7 +892,7 @@ import {
type RoutingSortingScope,
} from '@/features/routing/utils/routingPolicy'
import { RoutingFailoverPolicyEditor, RoutingPriorityPolicyEditor } from '@/features/routing/components'
import { validateRoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy, type RoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
import {
createRoutingGroup,
deleteRoutingGroup,
@@ -935,6 +937,7 @@ const groups = ref<RoutingGroupRecord[]>([])
const selectedGroupId = ref<string | null>(null)
const draft = ref<RoutingGroupDraft | null>(null)
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
const routingFailoverPending = ref(false)
const savedDraftSnapshot = ref<string | null>(null)
const sortingScope = ref<RoutingSortingScope>('unified')
const selectedPerModelName = ref<string | null>(null)
@@ -952,7 +955,7 @@ const groupActionId = ref<string | null>(null)
const draggedGroupId = ref<string | null>(null)
const dragOverGroupId = ref<string | null>(null)
const isCreating = ref(false)
let draftGeneration = 0
const draftGeneration = ref(0)
const switchModelTarget = ref<string | null>(null)
const switchModelDialogOpen = ref(false)
@@ -1098,7 +1101,8 @@ function paramToString(value: unknown): string | null {
}
function clearDraftState(): void {
draftGeneration += 1
draftGeneration.value += 1
routingFailoverPending.value = false
isCreating.value = false
selectedGroupId.value = null
draft.value = null
@@ -1113,7 +1117,8 @@ function clearDraftState(): void {
function selectGroup(group: RoutingGroupRecord): void {
const normalized = normalizeRecord(group)
draftGeneration += 1
draftGeneration.value += 1
routingFailoverPending.value = false
isCreating.value = false
selectedGroupId.value = normalized.id
draft.value = buildDraft(normalized)
@@ -1128,7 +1133,8 @@ function setDraftEnabled(value: boolean): void {
}
function startCreate(): void {
draftGeneration += 1
draftGeneration.value += 1
routingFailoverPending.value = false
isCreating.value = true
selectedGroupId.value = null
draft.value = {
@@ -1228,7 +1234,7 @@ const editingDirty = computed(() => {
const draftDirty = computed(() => {
if (!draft.value) return false
if (isCreating.value) return true
return savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
return routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
})
const canSaveDraft = computed(() => {
@@ -1349,6 +1355,15 @@ function updateExecutionPolicy(
})
}
function updateRoutingFailoverPolicy(value: RoutingFailoverPolicy): void {
if (!draft.value) return
const patch = normalizeRoutingFailoverPolicy(value)
Object.assign(draft.value.config_json.default_policy, patch)
if (editingConfig.value) {
Object.assign(editingConfig.value.default_policy, normalizeRoutingFailoverPolicy(patch))
}
}
function removePerModelPolicy(model: string): void {
if (!draft.value) return
if (perModelEditingActive.value && editingDirty.value) {
@@ -1658,19 +1673,19 @@ async function saveDraft(): Promise<void> {
return
}
if (routingFailoverPolicyEditor.value && !routingFailoverPolicyEditor.value.commitJsonDrafts()) return
const config = cloneConfig(draft.value.config_json)
const failoverError = validateRoutingFailoverPolicy(config.default_policy)
const failoverError = validateRoutingFailoverPolicy(draft.value.config_json.default_policy)
if (failoverError) {
showError(failoverError)
return
}
const config = cloneConfig(draft.value.config_json)
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
showError('按模型排序时至少选择一个模型')
return
}
const targetGroupId = draft.value.id ?? null
const submittedGeneration = draftGeneration
const submittedGeneration = draftGeneration.value
const submittedSnapshot = draftSnapshotValue(draft.value)
const wasCreating = isCreating.value || !draft.value.id
saving.value = true
@@ -1689,7 +1704,7 @@ async function saveDraft(): Promise<void> {
? await createRoutingGroup(payload)
: await updateRoutingGroup(targetGroupId, payload)
const sameDraftGeneration = draftGeneration === submittedGeneration
const sameDraftGeneration = draftGeneration.value === submittedGeneration
const stillEditingSubmittedDraft = wasCreating
? sameDraftGeneration
&& isCreateRoute.value
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, reactive, type App } from 'vue'
import RoutingProfiles from '../RoutingProfiles.vue'
import { createEmptyRoutingGroupConfig, getModelScheduling, savePerModelRoutingConfig } from '@/features/routing/utils/routingPolicy'
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
const routingApi = vi.hoisted(() => ({
listRoutingGroups: vi.fn(),
updateRoutingGroup: vi.fn(),
createRoutingGroup: vi.fn(),
deleteRoutingGroup: vi.fn(),
}))
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
const route = reactive({ name: 'RoutingProfileDetail', params: { groupId: 'strategy-a' } })
vi.mock('@/api/routing-profiles', () => routingApi)
vi.mock('@/api/global-models', () => ({ getGlobalModels: vi.fn().mockResolvedValue({ models: [] }) }))
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
vi.mock('@/features/routing/components', async () => ({
RoutingFailoverPolicyEditor: (await import('@/features/routing/components/RoutingFailoverPolicyEditor.vue')).default,
RoutingPriorityPolicyEditor: { render: () => null },
}))
const mounted: Array<{ app: App, root: HTMLElement }> = []
function group(id: string): RoutingGroupRecord {
return {
id,
name: id,
enabled: true,
is_system_default: false,
sort_order: 0,
config_json: createEmptyRoutingGroupConfig(),
version: 1,
created_at: 1,
updated_at: 1,
}
}
async function flush() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
async function mountPage(groups = [group('strategy-a'), group('strategy-b')]) {
routingApi.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
routingApi.updateRoutingGroup.mockImplementation(async (id: string, payload: RoutingGroupUpdateRequest) => ({
...groups.find(entry => entry.id === id),
...payload,
version: 2,
}))
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(RoutingProfiles)
app.mount(root)
mounted.push({ app, root })
await flush()
return root
}
function element<T extends HTMLElement>(root: HTMLElement, selector: string): T {
const found = root.querySelector<T>(selector)
if (!found) throw new Error(`Missing element: ${selector}`)
return found
}
function button(root: HTMLElement, label: string): HTMLButtonElement {
return element(root, `button[aria-label="${label}"]`)
}
async function input(root: HTMLElement, label: string, value: string) {
const field = element<HTMLInputElement | HTMLTextAreaElement>(root, `[aria-label="${label}"]`)
field.value = value
field.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
async function editJson(root: HTMLElement, section: string, value: string) {
button(root, `切到${section} JSON`).click()
await nextTick()
await input(root, `${section} JSON`, value)
}
beforeEach(() => {
vi.clearAllMocks()
route.name = 'RoutingProfileDetail'
route.params.groupId = 'strategy-a'
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('RoutingProfiles failover persistence', () => {
it('enables Save for JSON-only edits and persists both sections together', async () => {
const root = await mountPage()
expect(button(root, '保存').disabled).toBe(true)
await editJson(root, '成功转移规则', '[{"pattern":"(?i)capacity"}]')
await editJson(root, '错误终止规则', '[{"status_codes":[400,413]}]')
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules).toEqual({
success_failover_patterns: [{ pattern: '(?i)capacity', status_codes: [] }],
error_stop_patterns: [{ pattern: '', status_codes: [400, 413] }],
})
expect(toast.error).not.toHaveBeenCalled()
expect(button(root, '保存').disabled).toBe(true)
})
it('does not submit partial JSON drafts when either section is invalid', async () => {
const root = await mountPage()
await editJson(root, '成功转移规则', '[{"pattern":"capacity"}]')
await editJson(root, '错误终止规则', '{')
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
expect(root.querySelector('[role="alert"]')).not.toBeNull()
await input(root, '错误终止规则 JSON', '[{"status_codes":[429]}]')
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toHaveLength(1)
})
it('discards local rule drafts when navigating to another strategy', async () => {
const root = await mountPage()
await editJson(root, '成功转移规则', '[{"pattern":"only-strategy-a"}]')
route.params.groupId = 'strategy-b'
await flush()
expect(root.querySelector('textarea[aria-label="成功转移规则 JSON"]')).toBeNull()
expect(button(root, '保存').disabled).toBe(true)
await input(root, '全局最大转移次数', '3')
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup.mock.calls[0][0]).toBe('strategy-b')
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toEqual([])
})
it('preserves global failover edits while saving an independently edited model', async () => {
const strategy = group('strategy-a')
strategy.config_json = savePerModelRoutingConfig(strategy.config_json, 'model-a')
const root = await mountPage([strategy])
const configured = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '已配置')
configured?.click()
await nextTick()
const loadBalance = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '负载均衡')
if (!loadBalance) throw new Error('Missing model scheduling control')
loadBalance.click()
await nextTick()
await input(root, '全局最大转移次数', '5')
button(root, '添加错误终止规则').click()
await nextTick()
await input(root, '终止规则 1 状态码', '429')
expect(button(root, '保存').disabled).toBe(true)
element<HTMLButtonElement>(root, 'button[title="保存到草稿"]').click()
await nextTick()
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.default_policy.max_transfer_count).toBe(5)
expect(saved.default_policy.failover_rules.error_stop_patterns).toEqual([{ pattern: '', status_codes: [429] }])
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
expect(toast.error).not.toHaveBeenCalled()
})
})