fix(alembic,pipeline,resilience): 数据库迁移与异常处理健壮性修复

- alembic env: 改用事务级 advisory lock (pg_advisory_xact_lock),事务结束自动释放
- 迁移脚本: 使用原生 SQL IF NOT EXISTS/IF EXISTS 替代运行时列检查
- pipeline: SQLAlchemy 异常后先回滚事务再写审计,防止 aborted 状态二次报错
- resilience: ProgrammingError 标记为不可恢复,缩减 DB 重试异常范围
- 前端: 端点规则支持拖拽排序
This commit is contained in:
fawney19
2026-03-02 18:02:21 +08:00
parent 5f1c74aca0
commit 3384c6d666
5 changed files with 218 additions and 49 deletions

View File

@@ -51,7 +51,8 @@ if config.config_file_name is not None:
target_metadata = Base.metadata target_metadata = Base.metadata
# PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等) # PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等)
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可 # 使用事务级 advisory lockpg_advisory_xact_lock在迁移事务结束后自动释放。
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可。
MIGRATION_ADVISORY_LOCK_ID = 582694137405821 MIGRATION_ADVISORY_LOCK_ID = 582694137405821
@@ -89,31 +90,20 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
lock_acquired = False context.configure(
try: connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
if connection.dialect.name == "postgresql": if connection.dialect.name == "postgresql":
connection.execute( connection.execute(
text("SELECT pg_advisory_lock(:lock_id)"), text("SELECT pg_advisory_xact_lock(:lock_id)"),
{"lock_id": MIGRATION_ADVISORY_LOCK_ID},
)
lock_acquired = True
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
context.run_migrations()
finally:
if lock_acquired:
connection.rollback()
connection.execute(
text("SELECT pg_advisory_unlock(:lock_id)"),
{"lock_id": MIGRATION_ADVISORY_LOCK_ID}, {"lock_id": MIGRATION_ADVISORY_LOCK_ID},
) )
context.run_migrations()
# 根据模式选择运行方式 # 根据模式选择运行方式

View File

@@ -9,9 +9,6 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@@ -21,26 +18,12 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
columns = [c["name"] for c in insp.get_columns(table_name)]
return column_name in columns
def upgrade() -> None: def upgrade() -> None:
if not _column_exists("proxy_nodes", "proxy_metadata"): op.execute("ALTER TABLE public.proxy_nodes ADD COLUMN IF NOT EXISTS proxy_metadata json")
op.add_column( op.execute(
"proxy_nodes", "COMMENT ON COLUMN public.proxy_nodes.proxy_metadata IS 'aether-proxy 上报元数据(版本等)'"
sa.Column( )
"proxy_metadata",
sa.JSON(),
nullable=True,
comment="aether-proxy 上报元数据(版本等)",
),
)
def downgrade() -> None: def downgrade() -> None:
if _column_exists("proxy_nodes", "proxy_metadata"): op.execute("ALTER TABLE public.proxy_nodes DROP COLUMN IF EXISTS proxy_metadata")
op.drop_column("proxy_nodes", "proxy_metadata")

View File

@@ -208,12 +208,36 @@
</div> </div>
<CollapsibleContent class="pt-3"> <CollapsibleContent class="pt-3">
<div class="space-y-2"> <div class="space-y-2">
<div
v-if="getEndpointRulesCount(endpoint) > 1 || getEndpointBodyRulesCount(endpoint) > 1"
class="flex items-center gap-1.5 text-xs text-muted-foreground px-2"
>
<GripVertical class="w-3.5 h-3.5" />
<span>拖拽左侧手柄可调整规则执行顺序</span>
</div>
<!-- 请求头规则列表 - 主题色边框 --> <!-- 请求头规则列表 - 主题色边框 -->
<div <div
v-for="(rule, index) in getEndpointEditRules(endpoint.id)" v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
:key="`header-${index}`" :key="`header-${index}`"
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30" class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30"
:class="[
isHeaderRuleDragging(endpoint.id, index) ? 'opacity-60 border-primary bg-primary/5' : '',
isHeaderRuleDragOver(endpoint.id, index) ? 'ring-1 ring-primary/40 bg-primary/10' : ''
]"
@dragover.prevent="handleHeaderRuleDragOver(endpoint.id, index)"
@dragleave="handleHeaderRuleDragLeave(endpoint.id, index)"
@drop.prevent="handleHeaderRuleDrop(endpoint.id, index)"
> >
<button
type="button"
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
title="拖拽排序"
draggable="true"
@dragstart="(e) => handleHeaderRuleDragStart(endpoint.id, index, e)"
@dragend="() => handleHeaderRuleDragEnd(endpoint.id)"
>
<GripVertical class="w-3.5 h-3.5" />
</button>
<span <span
class="text-[10px] font-semibold text-primary shrink-0" class="text-[10px] font-semibold text-primary shrink-0"
title="请求头" title="请求头"
@@ -382,7 +406,24 @@
> >
<div <div
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-muted-foreground/40 bg-muted/30" class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-muted-foreground/40 bg-muted/30"
:class="[
isBodyRuleDragging(endpoint.id, index) ? 'opacity-60 border-muted-foreground/70 bg-muted/50' : '',
isBodyRuleDragOver(endpoint.id, index) ? 'ring-1 ring-muted-foreground/40 bg-muted/40' : ''
]"
@dragover.prevent="handleBodyRuleDragOver(endpoint.id, index)"
@dragleave="handleBodyRuleDragLeave(endpoint.id, index)"
@drop.prevent="handleBodyRuleDrop(endpoint.id, index)"
> >
<button
type="button"
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
title="拖拽排序"
draggable="true"
@dragstart="(e) => handleBodyRuleDragStart(endpoint.id, index, e)"
@dragend="() => handleBodyRuleDragEnd(endpoint.id)"
>
<GripVertical class="w-3.5 h-3.5" />
</button>
<span <span
class="text-[10px] font-semibold text-muted-foreground shrink-0" class="text-[10px] font-semibold text-muted-foreground shrink-0"
title="请求体" title="请求体"
@@ -781,7 +822,7 @@ import {
PopoverTrigger, PopoverTrigger,
PopoverContent, PopoverContent,
} from '@/components/ui' } from '@/components/ui'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle } from 'lucide-vue-next' import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
@@ -916,6 +957,130 @@ function handleBodyRuleSelectOpen(endpointId: string, index: number, open: boole
bodyRuleSelectOpen.value[`${endpointId}-${index}`] = open bodyRuleSelectOpen.value[`${endpointId}-${index}`] = open
} }
function clearHeaderRuleSelectOpen(endpointId: string) {
Object.keys(ruleSelectOpen.value).forEach((key) => {
if (key.startsWith(`${endpointId}-`)) {
delete ruleSelectOpen.value[key]
}
})
}
function clearBodyRuleSelectOpen(endpointId: string) {
Object.keys(bodyRuleSelectOpen.value).forEach((key) => {
if (key.startsWith(`${endpointId}-`)) {
delete bodyRuleSelectOpen.value[key]
}
})
}
function isHeaderRuleDragging(endpointId: string, index: number): boolean {
return headerRuleDraggedIndex.value[endpointId] === index
}
function isHeaderRuleDragOver(endpointId: string, index: number): boolean {
return headerRuleDragOverIndex.value[endpointId] === index
}
function isBodyRuleDragging(endpointId: string, index: number): boolean {
return bodyRuleDraggedIndex.value[endpointId] === index
}
function isBodyRuleDragOver(endpointId: string, index: number): boolean {
return bodyRuleDragOverIndex.value[endpointId] === index
}
function clearHeaderRuleDragState(endpointId: string) {
headerRuleDraggedIndex.value[endpointId] = null
headerRuleDragOverIndex.value[endpointId] = null
}
function clearBodyRuleDragState(endpointId: string) {
bodyRuleDraggedIndex.value[endpointId] = null
bodyRuleDragOverIndex.value[endpointId] = null
}
function handleHeaderRuleDragStart(endpointId: string, index: number, event: DragEvent) {
const rules = getEndpointEditRules(endpointId)
if (!rules[index]) return
headerRuleDraggedIndex.value[endpointId] = index
headerRuleDragOverIndex.value[endpointId] = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', `header:${endpointId}:${index}`)
}
}
function handleHeaderRuleDragOver(endpointId: string, index: number) {
const dragged = headerRuleDraggedIndex.value[endpointId]
if (dragged === null || dragged === undefined || dragged === index) return
headerRuleDragOverIndex.value[endpointId] = index
}
function handleHeaderRuleDragLeave(endpointId: string, index: number) {
if (headerRuleDragOverIndex.value[endpointId] === index) {
headerRuleDragOverIndex.value[endpointId] = null
}
}
function handleHeaderRuleDrop(endpointId: string, targetIndex: number) {
const dragIndex = headerRuleDraggedIndex.value[endpointId]
clearHeaderRuleDragState(endpointId)
if (dragIndex === null || dragIndex === undefined || dragIndex === targetIndex) return
const rules = getEndpointEditRules(endpointId)
if (dragIndex < 0 || dragIndex >= rules.length || targetIndex < 0 || targetIndex >= rules.length) return
const [draggedRule] = rules.splice(dragIndex, 1)
rules.splice(targetIndex, 0, draggedRule)
clearHeaderRuleSelectOpen(endpointId)
}
function handleHeaderRuleDragEnd(endpointId: string) {
clearHeaderRuleDragState(endpointId)
}
function handleBodyRuleDragStart(endpointId: string, index: number, event: DragEvent) {
const rules = getEndpointEditBodyRules(endpointId)
if (!rules[index]) return
bodyRuleDraggedIndex.value[endpointId] = index
bodyRuleDragOverIndex.value[endpointId] = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', `body:${endpointId}:${index}`)
}
}
function handleBodyRuleDragOver(endpointId: string, index: number) {
const dragged = bodyRuleDraggedIndex.value[endpointId]
if (dragged === null || dragged === undefined || dragged === index) return
bodyRuleDragOverIndex.value[endpointId] = index
}
function handleBodyRuleDragLeave(endpointId: string, index: number) {
if (bodyRuleDragOverIndex.value[endpointId] === index) {
bodyRuleDragOverIndex.value[endpointId] = null
}
}
function handleBodyRuleDrop(endpointId: string, targetIndex: number) {
const dragIndex = bodyRuleDraggedIndex.value[endpointId]
clearBodyRuleDragState(endpointId)
if (dragIndex === null || dragIndex === undefined || dragIndex === targetIndex) return
const rules = getEndpointEditBodyRules(endpointId)
if (dragIndex < 0 || dragIndex >= rules.length || targetIndex < 0 || targetIndex >= rules.length) return
const [draggedRule] = rules.splice(dragIndex, 1)
rules.splice(targetIndex, 0, draggedRule)
clearBodyRuleSelectOpen(endpointId)
}
function handleBodyRuleDragEnd(endpointId: string) {
clearBodyRuleDragState(endpointId)
}
// 状态 // 状态
const addingEndpoint = ref(false) const addingEndpoint = ref(false)
const savingEndpointId = ref<string | null>(null) const savingEndpointId = ref<string | null>(null)
@@ -937,6 +1102,12 @@ const bodyRuleSelectOpen = ref<Record<string, boolean>>({})
// 请求体规则说明 Popover 的展开状态 // 请求体规则说明 Popover 的展开状态
const bodyRuleHelpOpenEndpointId = ref<string | null>(null) const bodyRuleHelpOpenEndpointId = ref<string | null>(null)
// 规则拖拽状态(按 endpoint 维度)
const headerRuleDraggedIndex = ref<Record<string, number | null>>({})
const headerRuleDragOverIndex = ref<Record<string, number | null>>({})
const bodyRuleDraggedIndex = ref<Record<string, number | null>>({})
const bodyRuleDragOverIndex = ref<Record<string, number | null>>({})
function setBodyRuleHelpOpen(endpointId: string, open: boolean) { function setBodyRuleHelpOpen(endpointId: string, open: boolean) {
bodyRuleHelpOpenEndpointId.value = open ? endpointId : null bodyRuleHelpOpenEndpointId.value = open ? endpointId : null
} }
@@ -1230,6 +1401,8 @@ function handleAddEndpointRule(endpointId: string) {
function removeEndpointRule(endpointId: string, index: number) { function removeEndpointRule(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId) const rules = getEndpointEditRules(endpointId)
rules.splice(index, 1) rules.splice(index, 1)
clearHeaderRuleDragState(endpointId)
clearHeaderRuleSelectOpen(endpointId)
} }
// 更新规则类型 // 更新规则类型
@@ -1368,6 +1541,8 @@ function handleAddEndpointBodyRule(endpointId: string) {
function removeEndpointBodyRule(endpointId: string, index: number) { function removeEndpointBodyRule(endpointId: string, index: number) {
const rules = getEndpointEditBodyRules(endpointId) const rules = getEndpointEditBodyRules(endpointId)
rules.splice(index, 1) rules.splice(index, 1)
clearBodyRuleDragState(endpointId)
clearBodyRuleSelectOpen(endpointId)
} }
// 更新请求体规则类型 // 更新请求体规则类型
@@ -1933,6 +2108,12 @@ onMounted(() => {
// 监听 props 变化 // 监听 props 变化
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
bodyRuleHelpOpenEndpointId.value = null bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
bodyRuleSelectOpen.value = {}
headerRuleDraggedIndex.value = {}
headerRuleDragOverIndex.value = {}
bodyRuleDraggedIndex.value = {}
bodyRuleDragOverIndex.value = {}
if (open) { if (open) {
localEndpoints.value = [...(props.endpoints || [])] localEndpoints.value = [...(props.endpoints || [])]
// 清空编辑状态,重新从端点加载 // 清空编辑状态,重新从端点加载

View File

@@ -5,6 +5,7 @@ from enum import Enum
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.config.settings import config from src.config.settings import config
@@ -199,6 +200,12 @@ class ApiRequestPipeline:
except Exception as exc: except Exception as exc:
handle_duration = PerfRecorder.stop(handle_start, "pipeline_handle", labels=perf_labels) handle_duration = PerfRecorder.stop(handle_start, "pipeline_handle", labels=perf_labels)
_record_perf_metric("handle_ms", handle_duration) _record_perf_metric("handle_ms", handle_duration)
if isinstance(exc, SQLAlchemyError):
# SQL 执行失败后事务会进入 aborted 状态;先回滚,避免审计写入二次报错。
try:
context.db.rollback()
except Exception as rollback_exc:
logger.debug(f"[Pipeline] 回滚失败(可忽略): {rollback_exc}")
self._record_audit_event( self._record_audit_event(
context, context,
adapter, adapter,

View File

@@ -128,19 +128,27 @@ class ResilienceManager:
# 数据库连接错误 - 只捕获特定的数据库相关异常 # 数据库连接错误 - 只捕获特定的数据库相关异常
try: try:
from sqlalchemy.exc import ( from sqlalchemy.exc import (
DatabaseError,
DisconnectionError, DisconnectionError,
OperationalError, OperationalError,
StatementError, ProgrammingError,
) )
from sqlalchemy.exc import TimeoutError as SQLTimeoutError from sqlalchemy.exc import TimeoutError as SQLTimeoutError
# SQL/Schema 编程错误(如缺列/缺表)不应误判为“连接异常重试”。
self.add_error_pattern(
ErrorPattern(
error_types=[ProgrammingError],
severity=ErrorSeverity.HIGH,
recovery_strategy=RecoveryStrategy.USER_NOTIFY,
user_message="数据库结构与当前版本不兼容,请执行 alembic upgrade head 后重试",
auto_recover=False,
)
)
db_exceptions = [ db_exceptions = [
OperationalError, OperationalError,
DisconnectionError, DisconnectionError,
SQLTimeoutError, SQLTimeoutError,
StatementError,
DatabaseError,
] ]
except ImportError: except ImportError:
# 如果SQLAlchemy不可用使用通用异常类型 # 如果SQLAlchemy不可用使用通用异常类型