# 问题 修复 文件 1 前端构建失败(引号错误) size="small type=" → size="small" type=" PosterHistoryPage.vue 2 migrate_014 ORM vs 缺失列 全部改为原始 SQL,不再引用 ORM 模型 migrate_014.py 3 cleanup 字段名错误 output_path → ppt_path cleanup.py 4 文案生成 case 越权 添加 case.user_id != user_id 校验 poster/service.py 5 存储路径未接通持久化卷 全部改用 get_storage_root()(默认 /app/api/storage/insurance) config.py, ppt/routes.py, poster/service.py, poster/tasks.py 高风险问题修复 # 问题 修复 文件 6 migrate_019 rollback 撤销成功字段 每个 ALTER 后立即 commit,失败只回滚当前语句 migrate_019.py 7 迁移锁 Windows 不兼容 + 句柄未持久化 全局变量保存锁句柄,支持 Windows msvcrt api/insurance/db/__init__.py 8 PDF 校验异常时放行 异常返回 False(文件损坏) security.py 9 健康检查始终返回成功 缺少关键资源时返回 503 + missing 列表 poster/routes.py 10 短密钥掩码泄露原值 ≤4 字符返回 **** ppt_admin_service.py 11 设置无键名白名单 添加 _ALLOWED_SETTING_KEYS 白名单 ppt_admin_service.py 12 容器重启任务永久 stuck 添加 recover_stale_tasks() 启动恢复函数 poster/tasks.py, ppt/parse_worker.py
210 lines
6.3 KiB
Python
210 lines
6.3 KiB
Python
"""数据库迁移模块。
|
||
|
||
使用方式:
|
||
# 在应用启动时自动执行所有迁移
|
||
from insurance.db import run_migrations
|
||
run_migrations()
|
||
|
||
# 或手动执行单个迁移
|
||
from insurance.db.migrate_001 import migrate
|
||
migrate()
|
||
"""
|
||
import importlib
|
||
import logging
|
||
import os
|
||
from datetime import datetime
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _ensure_migration_table():
|
||
"""确保迁移历史表存在。"""
|
||
from insurance.db.compat import db
|
||
from sqlalchemy import text
|
||
|
||
db.session.execute(text("""
|
||
CREATE TABLE IF NOT EXISTS db_migration_history (
|
||
id SERIAL PRIMARY KEY,
|
||
migration_name VARCHAR(255) UNIQUE NOT NULL,
|
||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
"""))
|
||
db.session.commit()
|
||
|
||
|
||
_lock_file_handle = None # 持久化文件锁句柄,防止函数返回后锁释放
|
||
|
||
|
||
def _acquire_advisory_lock():
|
||
"""PostgreSQL advisory lock,防止多 Worker 并发执行迁移。
|
||
|
||
返回 True 表示获得锁,False 表示另一个进程正在迁移。
|
||
"""
|
||
global _lock_file_handle
|
||
from insurance.db.compat import db
|
||
from sqlalchemy import text
|
||
|
||
try:
|
||
result = db.session.execute(text("SELECT pg_try_advisory_lock(20260727)"))
|
||
locked = result.scalar()
|
||
return bool(locked)
|
||
except Exception:
|
||
pass
|
||
|
||
# 非 PostgreSQL:用文件锁替代(跨平台)
|
||
lock_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".migration.lock")
|
||
try:
|
||
_lock_file_handle = open(lock_path, "w")
|
||
try:
|
||
import fcntl
|
||
fcntl.flock(_lock_file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
return True
|
||
except ImportError:
|
||
# Windows: 使用 msvcrt
|
||
import msvcrt
|
||
msvcrt.locking(_lock_file_handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||
return True
|
||
except (IOError, OSError):
|
||
return False
|
||
|
||
|
||
def _release_advisory_lock():
|
||
"""释放 advisory lock。"""
|
||
global _lock_file_handle
|
||
from insurance.db.compat import db
|
||
from sqlalchemy import text
|
||
|
||
try:
|
||
db.session.execute(text("SELECT pg_advisory_unlock(20260727)"))
|
||
except Exception:
|
||
pass
|
||
|
||
if _lock_file_handle:
|
||
try:
|
||
try:
|
||
import fcntl
|
||
fcntl.flock(_lock_file_handle, fcntl.LOCK_UN)
|
||
except ImportError:
|
||
import msvcrt
|
||
_lock_file_handle.seek(0)
|
||
msvcrt.locking(_lock_file_handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||
_lock_file_handle.close()
|
||
except Exception:
|
||
pass
|
||
_lock_file_handle = None
|
||
|
||
|
||
def _get_executed_migrations():
|
||
"""获取已执行的迁移列表。"""
|
||
from insurance.db.compat import db
|
||
from sqlalchemy import text
|
||
|
||
try:
|
||
result = db.session.execute(text(
|
||
"SELECT migration_name FROM db_migration_history ORDER BY executed_at"
|
||
))
|
||
return {row[0] for row in result}
|
||
except Exception:
|
||
return set()
|
||
|
||
|
||
def _record_migration(migration_name: str):
|
||
"""记录迁移已执行。"""
|
||
from insurance.db.compat import db
|
||
from sqlalchemy import text
|
||
|
||
db.session.execute(text(
|
||
"INSERT INTO db_migration_history (migration_name, executed_at) VALUES (:name, :now)"
|
||
), {"name": migration_name, "now": datetime.now()})
|
||
db.session.commit()
|
||
|
||
|
||
def run_migrations():
|
||
"""自动执行所有迁移脚本(带幂等保护、事务回滚和并发锁)。"""
|
||
from flask import current_app
|
||
from insurance.db.compat import db
|
||
|
||
# 检查是否启用自动迁移
|
||
if not current_app.config.get("MIGRATION_ENABLED", False):
|
||
return
|
||
|
||
logger.info("开始执行数据库迁移...")
|
||
|
||
# 获取 advisory lock,防止多 Worker 并发迁移
|
||
if not _acquire_advisory_lock():
|
||
logger.info("另一个进程正在执行迁移,跳过")
|
||
return
|
||
|
||
try:
|
||
# 确保迁移历史表存在
|
||
_ensure_migration_table()
|
||
|
||
# 获取已执行的迁移
|
||
executed = _get_executed_migrations()
|
||
|
||
# 获取当前目录
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# 扫描所有迁移脚本(migrate_*.py)
|
||
migrations = []
|
||
for filename in sorted(os.listdir(current_dir)):
|
||
if filename.startswith("migrate_") and filename.endswith(".py"):
|
||
migrations.append(filename[:-3])
|
||
|
||
# 按顺序执行未执行的迁移
|
||
for migration_name in migrations:
|
||
if migration_name in executed:
|
||
logger.debug(f"跳过已执行的迁移: {migration_name}")
|
||
continue
|
||
try:
|
||
module = importlib.import_module(f"insurance.db.{migration_name}")
|
||
if hasattr(module, "migrate"):
|
||
logger.info(f"执行迁移: {migration_name}")
|
||
module.migrate()
|
||
_record_migration(migration_name)
|
||
logger.info(f"迁移 {migration_name} 完成")
|
||
except Exception as e:
|
||
# 回滚残留事务
|
||
try:
|
||
db.session.rollback()
|
||
except Exception:
|
||
pass
|
||
logger.error(f"迁移 {migration_name} 失败: {e}")
|
||
# 失败后停止,不带病继续
|
||
raise RuntimeError(f"迁移 {migration_name} 失败,中止启动: {e}") from e
|
||
|
||
logger.info("迁移执行完成")
|
||
finally:
|
||
_release_advisory_lock()
|
||
|
||
|
||
def get_migration_status():
|
||
"""获取迁移执行状态。"""
|
||
from insurance.db.compat import db
|
||
from sqlalchemy import text
|
||
|
||
try:
|
||
# 检查迁移历史表是否存在
|
||
result = db.session.execute(text("""
|
||
SELECT EXISTS (
|
||
SELECT FROM information_schema.tables
|
||
WHERE table_name = 'db_migration_history'
|
||
)
|
||
"""))
|
||
|
||
if not result.fetchone()[0]:
|
||
return {"status": "no_history_table", "migrations": []}
|
||
|
||
# 获取已执行的迁移
|
||
result = db.session.execute(text("""
|
||
SELECT migration_name, executed_at
|
||
FROM db_migration_history
|
||
ORDER BY executed_at DESC
|
||
"""))
|
||
|
||
migrations = [dict(row) for row in result]
|
||
return {"status": "ok", "migrations": migrations}
|
||
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|