baodan/api/insurance/db/migrate_019.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

162 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.

"""迁移 019: 修复已有 schema 和数据问题。
修复内容:
1. 转换 slides_config_json 中的对象格式 {"slides": [...]} 为数组格式 [...]
2. 为 poster_records 表添加生成方式追踪字段
3. 为常用查询添加索引
"""
import json
import logging
from sqlalchemy import text, inspect
logger = logging.getLogger(__name__)
def migrate():
"""执行迁移。"""
from insurance.db.compat import db
dialect = db.session.bind.dialect.name
_fix_slides_config_json(db)
_add_poster_record_fields(db, dialect)
_add_indexes(db, dialect)
db.session.commit()
def _column_exists(db, table_name: str, column_name: str) -> bool:
"""检查列是否存在。"""
dialect = db.session.bind.dialect.name
if dialect == "postgresql":
result = db.session.execute(text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
), {"table": table_name, "column": column_name})
return result.scalar() > 0
else:
try:
result = db.session.execute(text(f"PRAGMA table_info({table_name})"))
return any(row[1] == column_name for row in result.fetchall())
except Exception:
return False
def _fix_slides_config_json(db):
"""将对象格式 {"slides": [...]} 转换为数组格式 [...]。
历史数据中 migrate_017 写入的是 {"slides": [...]}
但渲染器和前端均期望数组格式。
"""
templates = db.session.execute(text(
"SELECT id, slides_config_json FROM insurance_ppt_templates "
"WHERE slides_config_json IS NOT NULL"
)).fetchall()
fixed_count = 0
for tpl_id, config_json in templates:
if not config_json or not config_json.strip():
continue
try:
config = json.loads(config_json)
except (json.JSONDecodeError, TypeError):
logger.warning(f"模板 {tpl_id} 的 slides_config_json 无法解析,跳过")
continue
# 如果是对象格式 {"slides": [...]},转换为数组
if isinstance(config, dict) and "slides" in config:
slides_array = config["slides"]
if isinstance(slides_array, list):
db.session.execute(text(
"UPDATE insurance_ppt_templates SET slides_config_json = :config WHERE id = :id"
), {"config": json.dumps(slides_array, ensure_ascii=False), "id": tpl_id})
fixed_count += 1
logger.info(f"模板 {tpl_id}: 对象格式已转换为数组格式")
else:
logger.warning(f"模板 {tpl_id}: slides 值不是数组,跳过")
elif isinstance(config, list):
pass # 已经是数组格式,无需处理
else:
logger.warning(f"模板 {tpl_id}: 未知格式 {type(config).__name__},跳过")
if fixed_count > 0:
logger.info(f"已修复 {fixed_count} 个模板的 slides_config_json 格式")
def _add_poster_record_fields(db, dialect):
"""为 poster_records 表添加生成方式追踪和任务状态字段。"""
new_columns = [
("generation_mode", "VARCHAR(20)", "生成方式: ai/fallback"),
("image_provider", "VARCHAR(50)", "图片供应商"),
("image_model", "VARCHAR(100)", "图片模型名称"),
("task_status", "VARCHAR(20)", "任务状态: pending/queued/generating/done/failed"),
("task_progress", "INTEGER", "任务进度 0-100"),
("task_error", "TEXT", "任务错误信息"),
("started_at", "TIMESTAMP", "任务开始时间"),
("finished_at", "TIMESTAMP", "任务完成时间"),
]
for col_name, col_type, comment in new_columns:
if _column_exists(db, "poster_records", col_name):
logger.debug(f"poster_records.{col_name} 已存在,跳过")
continue
try:
db.session.execute(text(
f"ALTER TABLE poster_records ADD COLUMN {col_name} {col_type}"
))
db.session.commit()
logger.info(f"已添加 poster_records.{col_name} ({comment})")
except Exception as e:
db.session.rollback()
logger.warning(f"添加 poster_records.{col_name} 失败(已回滚,不影响其他字段): {e}")
def _add_indexes(db, dialect):
"""为常用查询添加索引。"""
indexes = [
("idx_poster_records_user_id", "poster_records", ["user_id"]),
("idx_poster_records_created_at", "poster_records", ["created_at"]),
("idx_poster_case_uploads_user_id", "poster_case_uploads", ["user_id"]),
("idx_ppt_history_user_id", "insurance_ppt_history", ["user_id"]),
("idx_ppt_history_created_at", "insurance_ppt_history", ["created_at"]),
("idx_system_settings_key", "system_settings", ["key"]),
]
for idx_name, table_name, columns in indexes:
try:
# 检查表是否存在
if dialect == "postgresql":
result = db.session.execute(text(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table"
), {"table": table_name})
if result.scalar() == 0:
continue
else:
try:
db.session.execute(text(f"SELECT 1 FROM {table_name} LIMIT 0"))
except Exception:
continue
# 检查索引是否已存在
if dialect == "postgresql":
result = db.session.execute(text(
"SELECT COUNT(*) FROM pg_indexes WHERE indexname = :idx"
), {"idx": idx_name})
if result.scalar() > 0:
continue
else:
try:
result = db.session.execute(text(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name = :idx"
), {"idx": idx_name})
if result.scalar() > 0:
continue
except Exception:
continue
cols = ", ".join(columns)
db.session.execute(text(f"CREATE INDEX {idx_name} ON {table_name} ({cols})"))
logger.info(f"已创建索引 {idx_name}")
except Exception as e:
logger.warning(f"创建索引 {idx_name} 失败: {e}")
db.session.rollback()