baodan/api/insurance/db/__init__.py
wsb1224 e3479f0546 上线阻断问题全部修复
#	问题	修复	文件
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
2026-07-27 13:52:09 +08:00

210 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""数据库迁移模块。
使用方式:
# 在应用启动时自动执行所有迁移
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)}