baodan/api/insurance/utils/cleanup.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

143 lines
4.8 KiB
Python

"""数据保留和清理任务。
根据 system_settings 中的 retention_days 配置,清理过期的海报和 PPT 文件及记录。
支持手动触发和定时调用。
"""
import logging
import os
from datetime import datetime, timedelta
from sqlalchemy import text
logger = logging.getLogger(__name__)
def cleanup_expired_data(dry_run: bool = False) -> dict:
"""清理过期数据。
参数:
dry_run: True 时只统计不实际删除
返回:
{"poster_deleted": N, "ppt_deleted": N, "files_deleted": N, "errors": [...]}
"""
from insurance.db.compat import db
from insurance.models.system_setting import SystemSetting
stats = {"poster_deleted": 0, "ppt_deleted": 0, "files_deleted": 0, "errors": []}
# 读取保留天数配置
settings = {s.key: s.value for s in SystemSetting.query.filter(
SystemSetting.key.in_(["poster_history_retention_days", "ppt_history_retention_days"])
).all()}
poster_days = _parse_int(settings.get("poster_history_retention_days"), 365)
ppt_days = _parse_int(settings.get("ppt_history_retention_days"), 365)
# 清理过期海报记录
if poster_days > 0:
cutoff = datetime.now() - timedelta(days=poster_days)
try:
_cleanup_poster_records(db, cutoff, dry_run, stats)
except Exception as e:
stats["errors"].append(f"poster cleanup error: {e}")
logger.error(f"海报清理失败: {e}")
# 清理过期 PPT 历史
if ppt_days > 0:
cutoff = datetime.now() - timedelta(days=ppt_days)
try:
_cleanup_ppt_history(db, cutoff, dry_run, stats)
except Exception as e:
stats["errors"].append(f"ppt cleanup error: {e}")
logger.error(f"PPT 历史清理失败: {e}")
logger.info(f"数据清理完成: {stats}")
return stats
def _cleanup_poster_records(db, cutoff: datetime, dry_run: bool, stats: dict):
"""清理过期海报记录和文件。"""
from insurance.models.poster_record import PosterRecord
expired = PosterRecord.query.filter(
PosterRecord.created_at < cutoff,
PosterRecord.task_status.in_(["done", "failed"]),
).all()
for record in expired:
# 删除物理文件
if record.export_url and os.path.exists(record.export_url):
if not dry_run:
try:
os.remove(record.export_url)
stats["files_deleted"] += 1
except Exception as e:
stats["errors"].append(f"file delete error {record.export_url}: {e}")
else:
stats["files_deleted"] += 1
# 匿名化数据库记录(保留合规留痕,删除用户标识)
if not dry_run:
record.user_id = "deleted"
record.export_url = None
# 保留 prompt_used, ai_raw_content 等合规字段
stats["poster_deleted"] += 1
if not dry_run and expired:
db.session.commit()
def _cleanup_ppt_history(db, cutoff: datetime, dry_run: bool, stats: dict):
"""清理过期 PPT 历史记录和文件。"""
rows = db.session.execute(text(
"SELECT id, file_url FROM insurance_ppt_history WHERE created_at < :cutoff"
), {"cutoff": cutoff}).fetchall()
for row in rows:
record_id, file_url = row
# 删除物理文件
if file_url and os.path.exists(file_url):
if not dry_run:
try:
os.remove(file_url)
stats["files_deleted"] += 1
except Exception as e:
stats["errors"].append(f"file delete error {file_url}: {e}")
else:
stats["files_deleted"] += 1
# 匿名化记录
if not dry_run:
db.session.execute(text(
"UPDATE insurance_ppt_history SET user_id = 'deleted', file_url = NULL WHERE id = :id"
), {"id": record_id})
stats["ppt_deleted"] += 1
if not dry_run and rows:
db.session.commit()
# 清理过期 PPT 会话文件
sessions = db.session.execute(text(
"SELECT id, ppt_path FROM insurance_ppt_sessions "
"WHERE created_at < :cutoff AND status = 'done'"
), {"cutoff": cutoff}).fetchall()
for session_id, ppt_path in sessions:
if ppt_path and os.path.exists(ppt_path):
if not dry_run:
try:
os.remove(ppt_path)
stats["files_deleted"] += 1
except Exception as e:
stats["errors"].append(f"session file delete error: {e}")
else:
stats["files_deleted"] += 1
def _parse_int(value: str, default: int) -> int:
try:
return int(str(value or "").strip())
except (TypeError, ValueError):
return default