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
# PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等)
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可
# 使用事务级 advisory lockpg_advisory_xact_lock在迁移事务结束后自动释放。
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可。
MIGRATION_ADVISORY_LOCK_ID = 582694137405821
@@ -89,31 +90,20 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
lock_acquired = False
try:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
if connection.dialect.name == "postgresql":
connection.execute(
text("SELECT pg_advisory_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)"),
text("SELECT pg_advisory_xact_lock(: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
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
@@ -21,26 +18,12 @@ branch_labels: 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:
if not _column_exists("proxy_nodes", "proxy_metadata"):
op.add_column(
"proxy_nodes",
sa.Column(
"proxy_metadata",
sa.JSON(),
nullable=True,
comment="aether-proxy 上报元数据(版本等)",
),
)
op.execute("ALTER TABLE public.proxy_nodes ADD COLUMN IF NOT EXISTS proxy_metadata json")
op.execute(
"COMMENT ON COLUMN public.proxy_nodes.proxy_metadata IS 'aether-proxy 上报元数据(版本等)'"
)
def downgrade() -> None:
if _column_exists("proxy_nodes", "proxy_metadata"):
op.drop_column("proxy_nodes", "proxy_metadata")
op.execute("ALTER TABLE public.proxy_nodes DROP COLUMN IF EXISTS proxy_metadata")

View File

@@ -208,12 +208,36 @@
</div>
<CollapsibleContent class="pt-3">
<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
v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
: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="[
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
class="text-[10px] font-semibold text-primary shrink-0"
title="请求头"
@@ -382,7 +406,24 @@
>
<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="[
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
class="text-[10px] font-semibold text-muted-foreground shrink-0"
title="请求体"
@@ -781,7 +822,7 @@ import {
PopoverTrigger,
PopoverContent,
} 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 { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
@@ -916,6 +957,130 @@ function handleBodyRuleSelectOpen(endpointId: string, index: number, open: boole
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 savingEndpointId = ref<string | null>(null)
@@ -937,6 +1102,12 @@ const bodyRuleSelectOpen = ref<Record<string, boolean>>({})
// 请求体规则说明 Popover 的展开状态
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) {
bodyRuleHelpOpenEndpointId.value = open ? endpointId : null
}
@@ -1230,6 +1401,8 @@ function handleAddEndpointRule(endpointId: string) {
function removeEndpointRule(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
rules.splice(index, 1)
clearHeaderRuleDragState(endpointId)
clearHeaderRuleSelectOpen(endpointId)
}
// 更新规则类型
@@ -1368,6 +1541,8 @@ function handleAddEndpointBodyRule(endpointId: string) {
function removeEndpointBodyRule(endpointId: string, index: number) {
const rules = getEndpointEditBodyRules(endpointId)
rules.splice(index, 1)
clearBodyRuleDragState(endpointId)
clearBodyRuleSelectOpen(endpointId)
}
// 更新请求体规则类型
@@ -1933,6 +2108,12 @@ onMounted(() => {
// 监听 props 变化
watch(() => props.modelValue, (open) => {
bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
bodyRuleSelectOpen.value = {}
headerRuleDraggedIndex.value = {}
headerRuleDragOverIndex.value = {}
bodyRuleDraggedIndex.value = {}
bodyRuleDragOverIndex.value = {}
if (open) {
localEndpoints.value = [...(props.endpoints || [])]
// 清空编辑状态,重新从端点加载

View File

@@ -5,6 +5,7 @@ from enum import Enum
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from src.config.settings import config
@@ -199,6 +200,12 @@ class ApiRequestPipeline:
except Exception as exc:
handle_duration = PerfRecorder.stop(handle_start, "pipeline_handle", labels=perf_labels)
_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(
context,
adapter,

View File

@@ -128,19 +128,27 @@ class ResilienceManager:
# 数据库连接错误 - 只捕获特定的数据库相关异常
try:
from sqlalchemy.exc import (
DatabaseError,
DisconnectionError,
OperationalError,
StatementError,
ProgrammingError,
)
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 = [
OperationalError,
DisconnectionError,
SQLTimeoutError,
StatementError,
DatabaseError,
]
except ImportError:
# 如果SQLAlchemy不可用使用通用异常类型