feat(failover): 支持 Provider 级别故障转移规则,默认全部转移策略

- 新增 failover_rules 配置:支持 success_failover_patterns(成功响应匹配时转移)
  和 error_stop_patterns(错误响应匹配时终止),支持按 status_code 过滤
- 修改默认转移策略:ErrorClassifier 不再返回 RAISE,所有错误默认继续转移
- TaskService 中客户端错误不再直接抛出,改为 break 继续尝试下一个候选
- 修复 proxy tunnel 连接/断连竞态:引入 per-node 锁和事件时间戳排序
- 优化 ProxyNode 状态判定:OFFLINE 统一由心跳超时判定,兼容多 worker 场景
- has_tunnel 改为纯检查方法,避免在 finally 块中误清理新注册连接
- Redis stream NOGROUP 异常自愈处理
- OAuthAccountDialog 输入框焦点样式补全
This commit is contained in:
fawney19
2026-03-01 00:21:16 +08:00
parent fbcb54a8a5
commit 005cc3e388
18 changed files with 941 additions and 116 deletions

View File

@@ -1,6 +1,7 @@
import client from '../client'
import type {
ClaudeCodeAdvancedConfig,
FailoverRulesConfig,
PoolAdvancedConfig,
ProviderWithEndpointsSummary,
ProxyConfig,
@@ -48,6 +49,7 @@ export async function updateProvider(
is_active: boolean
claude_code_advanced: ClaudeCodeAdvancedConfig | null
pool_advanced: PoolAdvancedConfig | null
failover_rules: FailoverRulesConfig | null
}>
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
@@ -77,6 +79,7 @@ export async function createProvider(
proxy?: ProxyConfig | null
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
pool_advanced?: PoolAdvancedConfig | null
failover_rules?: FailoverRulesConfig | null
}
): Promise<{ id: string; name: string; message?: string }> {
const response = await client.post('/api/admin/providers/', data)

View File

@@ -471,6 +471,17 @@ export interface PoolAdvancedConfig {
unschedulable_rules?: Array<Record<string, unknown>> | null
}
export interface FailoverRuleItem {
pattern: string
description?: string
status_codes?: number[]
}
export interface FailoverRulesConfig {
success_failover_patterns: FailoverRuleItem[]
error_stop_patterns: FailoverRuleItem[]
}
export interface ProviderWithEndpointsSummary {
id: string
name: string
@@ -506,6 +517,7 @@ export interface ProviderWithEndpointsSummary {
endpoint_health_details: EndpointHealthDetail[]
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
pool_advanced?: PoolAdvancedConfig | null
failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
created_at: string

View File

@@ -0,0 +1,253 @@
<template>
<Dialog
:model-value="open"
title="故障转移规则"
description="配置提供商级别的故障转移规则。默认所有错误都会触发转移,此处可自定义例外。"
:icon="GitBranch"
size="lg"
@update:model-value="handleClose"
>
<div class="space-y-5 max-h-[60vh] overflow-y-auto px-0.5 py-0.5 -mx-0.5">
<!-- 成功转移规则 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">
成功转移规则
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
HTTP 200 但响应体匹配正则时视为失败并触发转移
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
@click="addRule('success')"
>
<Plus class="w-4 h-4 mr-1" />
添加
</Button>
</div>
<div
v-if="successPatterns.length === 0"
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
>
暂无规则
</div>
<div
v-for="(rule, index) in successPatterns"
:key="'s-' + index"
class="flex items-center gap-1"
>
<Input
v-model="rule.pattern"
placeholder="例如: relay:.*格式错误"
class="font-mono text-xs flex-1"
/>
<Button
variant="ghost"
size="sm"
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
@click="removeRule('success', index)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 错误终止规则 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">
错误终止规则
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
HTTP 200 且响应体匹配正则时停止转移并直接返回错误可选填状态码缩小匹配范围
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
@click="addRule('error')"
>
<Plus class="w-4 h-4 mr-1" />
添加
</Button>
</div>
<div
v-if="errorPatterns.length === 0"
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
>
暂无规则
</div>
<div
v-for="(rule, index) in errorPatterns"
:key="'e-' + index"
class="flex items-center gap-1"
>
<Input
:model-value="formatStatusCodes(rule.status_codes)"
placeholder="状态码"
size="sm"
class="font-mono text-xs w-24 shrink-0"
@update:model-value="(v: string | number) => rule.status_codes = parseStatusCodes(String(v))"
/>
<Input
v-model="rule.pattern"
placeholder="例如: content_policy_violation"
class="font-mono text-xs flex-1"
/>
<Button
variant="ghost"
size="sm"
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
@click="removeRule('error', index)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
:disabled="saving"
@click="handleClose"
>
取消
</Button>
<Button
:disabled="saving"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
Dialog,
Button,
Input,
} from '@/components/ui'
import { GitBranch, Plus, Trash2 } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import { parseApiError } from '@/utils/errorParser'
import type { FailoverRuleItem } from '@/api/endpoints/types'
const props = defineProps<{
open: boolean
provider: ProviderWithEndpointsSummary | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'saved': []
}>()
const { success, error: showError } = useToast()
const saving = ref(false)
const successPatterns = ref<FailoverRuleItem[]>([])
const errorPatterns = ref<FailoverRuleItem[]>([])
watch(() => [props.open, props.provider], () => {
if (props.open && props.provider) {
const rules = props.provider.failover_rules
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({ ...r }))
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({ ...r }))
}
}, { immediate: true })
function addRule(type: 'success' | 'error') {
const rule: FailoverRuleItem = { pattern: '', description: '' }
if (type === 'success') {
successPatterns.value.push(rule)
} else {
errorPatterns.value.push(rule)
}
}
function removeRule(type: 'success' | 'error', index: number) {
if (type === 'success') {
successPatterns.value.splice(index, 1)
} else {
errorPatterns.value.splice(index, 1)
}
}
function handleClose() {
emit('update:open', false)
}
function formatStatusCodes(codes: number[] | undefined): string {
if (!codes || codes.length === 0) return ''
return codes.join(',')
}
function parseStatusCodes(input: string): number[] | undefined {
const trimmed = input.trim()
if (!trimmed) return undefined
const codes = trimmed
.split(/[,\s]+/)
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n >= 100 && n <= 599)
return codes.length > 0 ? codes : undefined
}
async function handleSave() {
if (!props.provider) return
// Validate patterns
const allPatterns = [...successPatterns.value, ...errorPatterns.value]
for (const rule of allPatterns) {
if (!rule.pattern.trim()) {
showError('正则表达式不能为空', '验证失败')
return
}
try {
new RegExp(rule.pattern)
} catch {
showError(`无效的正则表达式: ${rule.pattern}`, '验证失败')
return
}
}
saving.value = true
try {
const filteredSuccess = successPatterns.value.filter(r => r.pattern.trim())
const filteredError = errorPatterns.value.filter(r => r.pattern.trim())
const hasRules = filteredSuccess.length > 0 || filteredError.length > 0
await updateProvider(props.provider.id, {
failover_rules: hasRules
? {
success_failover_patterns: filteredSuccess,
error_stop_patterns: filteredError,
}
: null,
})
success('故障转移规则已保存')
emit('saved')
handleClose()
} catch (err: unknown) {
showError(parseApiError(err, '保存故障转移规则失败'), '保存失败')
} finally {
saving.value = false
}
}
</script>

View File

@@ -138,7 +138,7 @@
v-model="device.start_url"
type="text"
placeholder="https://your-org.awsapps.com/start"
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono"
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
spellcheck="false"
>
</div>
@@ -154,7 +154,7 @@
<ComboboxInput
:display-value="() => device.region"
placeholder="输入或选择 Region"
class="w-full h-8 px-2 pr-7 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring"
class="w-full h-8 px-2 pr-7 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
spellcheck="false"
@input="(e: Event) => regionSearch = (e.target as HTMLInputElement).value"
@keydown.enter.prevent="onRegionEnter"
@@ -193,7 +193,7 @@
v-model="device.totp_secret"
type="text"
placeholder="Base32 secret, JBSWY3DPEHPK3PXP"
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono"
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
spellcheck="false"
>
</div>

View File

@@ -50,6 +50,16 @@
<Shuffle class="w-4 h-4" />
</Button>
</span>
<span :title="hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则'">
<Button
variant="ghost"
size="icon"
:class="hasFailoverRules ? 'text-orange-500 dark:text-orange-400' : ''"
@click="failoverRulesDialogOpen = true"
>
<GitBranch class="w-4 h-4" />
</Button>
</span>
<Popover
:open="providerProxyPopoverOpen"
@update:open="handleProviderProxyPopoverToggle"
@@ -1029,6 +1039,14 @@
:key-id="antigravityQuotaDialogKey.id"
@update:open="antigravityQuotaDialogOpen = $event"
/>
<!-- 故障转移规则弹窗 -->
<FailoverRulesDialog
:open="failoverRulesDialogOpen"
:provider="provider ?? null"
@update:open="failoverRulesDialogOpen = $event"
@saved="loadProvider()"
/>
</template>
<script setup lang="ts">
@@ -1050,6 +1068,7 @@ import {
BarChart3,
ShieldX,
Globe,
GitBranch,
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -1084,6 +1103,7 @@ import EndpointFormDialog from '@/features/providers/components/EndpointFormDial
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import AlertDialog from '@/components/common/AlertDialog.vue'
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
import FailoverRulesDialog from '@/features/providers/components/FailoverRulesDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import {
@@ -1189,6 +1209,15 @@ const refreshingQuota = ref(false)
const antigravityQuotaDialogOpen = ref(false)
const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
// 故障转移规则
const failoverRulesDialogOpen = ref(false)
const hasFailoverRules = computed(() => {
const rules = provider.value?.failover_rules
if (!rules) return false
return (rules.success_failover_patterns?.length || 0) > 0
|| (rules.error_stop_patterns?.length || 0) > 0
})
// Provider 级别代理配置状态
const proxyNodesStore = useProxyNodesStore()
const providerProxyPopoverOpen = ref(false)