1 poster/routes.py:156 下载路径硬编码,与实际存储路径不一致 → 下载 404 P0
2 poster/service.py:41 get_reviewed_products() N+1 查询 P1
3 poster/service.py:169,226 json.loads 无异常处理 → 数据损坏时崩溃 P1
4 3 个 model 文件 to_dict() 中 json.loads 无防御 → 序列化崩溃 P1
5 poster/tasks.py ~120 行死代码(线程版海报生成) P2
6 generation/celery_tasks.py parse_poster_task + _execute_poster_parse 死代码(~70 行) P2
7 generation/task_service.py 对应移除 ("poster", "parse") 映射 P2
8 utils/security.py SSRF TOCTOU:DNS 检查与请求之间的时间窗口可被 DNS rebinding 利用 → 新增 _SafeHTTPTransport 在连接时重新验证 IP P1 安全
9 poster/image_generator.py anchor="mm" 在旧 Pillow 默认字体上崩溃;改用 hasattr 检测 P1
前端(3 项)
# 文件 问题 严重度
10 poster-api.ts:54 downloadPoster() 返回 AxiosResponse 而非 Blob → 海报永远无法下载 P0
11 PosterStepUpload.vue 解析轮询无超时 → 无限轮询 P1
12 PosterStepPreview.vue 生成轮询无超时 → 无限轮询 P1
修改的文件总计
后端 7 个:security.py, image_generator.py, service.py, routes.py, tasks.py, celery_tasks.py, task_service.py, poster_case_upload.py, poster_record.py, poster_template_model.py
前端 3 个:poster-api.ts, PosterStepUpload.vue, PosterStepPreview.vue
未修复(确认无需修复)
manual_parser.py — 之前误判为死代码,实际被 Celery 产品小册子解析任务使用,保留不动
llm_client.py 中的 httpx 调用 — URL 来自管理员配置的系统设置,不是用户输入,SSRF 风险极低;加检查反而会阻断合法的私网 LLM 端点
163 lines
5.1 KiB
Python
163 lines
5.1 KiB
Python
"""海报后台任务管理。
|
||
|
||
使用与 PPT parse_worker 相同的后台线程 + Redis 锁模式。
|
||
任务状态通过数据库字段追踪,前端轮询获取进度。
|
||
"""
|
||
import json
|
||
import logging
|
||
import threading
|
||
import os
|
||
from datetime import datetime
|
||
|
||
from insurance.db.compat import db
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_local_locks: set = set()
|
||
_local_locks_guard = threading.Lock()
|
||
_redis_locks: set = set()
|
||
|
||
# 超过此时间仍为 queued/generating 的任务视为过期(秒)
|
||
STALE_TASK_TIMEOUT = 600 # 10 分钟
|
||
|
||
|
||
def recover_stale_tasks():
|
||
"""启动时恢复过期任务:将长时间 queued/generating 的任务标记为 failed。
|
||
|
||
应在应用启动时调用。
|
||
"""
|
||
from insurance.models.poster_record import PosterRecord
|
||
from insurance.models.poster_case_upload import PosterCaseUpload
|
||
|
||
cutoff = datetime.now().timestamp() - STALE_TASK_TIMEOUT
|
||
|
||
# 恢复过期的海报生成任务
|
||
stale_records = PosterRecord.query.filter(
|
||
PosterRecord.task_status.in_(["queued", "generating"]),
|
||
PosterRecord.created_at < datetime.fromtimestamp(cutoff),
|
||
).all()
|
||
for record in stale_records:
|
||
record.task_status = "failed"
|
||
record.task_error = "任务因服务重启而中断,请重新生成"
|
||
record.finished_at = datetime.now()
|
||
logger.warning(f"恢复过期海报任务: record_id={record.id}")
|
||
|
||
# 恢复过期的计划书解析任务
|
||
stale_cases = PosterCaseUpload.query.filter(
|
||
PosterCaseUpload.parse_status.in_(["queued", "parsing"]),
|
||
PosterCaseUpload.created_at < datetime.fromtimestamp(cutoff),
|
||
).all()
|
||
for case in stale_cases:
|
||
case.parse_status = "failed"
|
||
logger.warning(f"恢复过期解析任务: case_id={case.id}")
|
||
|
||
if stale_records or stale_cases:
|
||
db.session.commit()
|
||
logger.info(f"已恢复 {len(stale_records)} 个海报任务, {len(stale_cases)} 个解析任务")
|
||
|
||
|
||
# ─── 计划书解析任务 ───────────────────────────────────────
|
||
|
||
def start_case_parse_task(app, case_upload_id: int) -> bool:
|
||
"""启动后台计划书解析任务,返回是否新启动。"""
|
||
lock_key = f"poster_case_parse:{case_upload_id}"
|
||
if not _acquire_lock(lock_key):
|
||
return False
|
||
|
||
thread = threading.Thread(
|
||
target=_run_case_parse_task,
|
||
args=(app, case_upload_id, lock_key),
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
return True
|
||
|
||
|
||
def _run_case_parse_task(app, case_upload_id: int, lock_key: str):
|
||
with app.app_context():
|
||
try:
|
||
_execute_case_parse(case_upload_id)
|
||
except Exception as exc:
|
||
logger.error(f"计划书解析任务失败 [{case_upload_id}]: {exc}", exc_info=True)
|
||
_mark_case_failed(case_upload_id, str(exc))
|
||
finally:
|
||
_release_lock(lock_key)
|
||
db.session.remove()
|
||
|
||
|
||
def _execute_case_parse(case_upload_id: int):
|
||
from insurance.models.poster_case_upload import PosterCaseUpload
|
||
|
||
record = PosterCaseUpload.query.get(case_upload_id)
|
||
if not record:
|
||
return
|
||
|
||
filepath = record.source_file_url
|
||
if not filepath or not os.path.exists(filepath):
|
||
record.parse_status = "failed"
|
||
db.session.commit()
|
||
return
|
||
|
||
record.parse_status = "parsing"
|
||
db.session.commit()
|
||
|
||
from insurance.ppt.extraction import ExtractionOrchestrator
|
||
orchestrator = ExtractionOrchestrator(use_cache=False)
|
||
|
||
import asyncio
|
||
parsed = asyncio.run(orchestrator.extract_for_poster(filepath))
|
||
|
||
record = PosterCaseUpload.query.get(case_upload_id)
|
||
if not record:
|
||
return
|
||
record.parsed_data = json.dumps(parsed, ensure_ascii=False)
|
||
record.parse_status = "parsed"
|
||
db.session.commit()
|
||
|
||
|
||
def _mark_case_failed(case_upload_id: int, error: str):
|
||
from insurance.models.poster_case_upload import PosterCaseUpload
|
||
|
||
record = PosterCaseUpload.query.get(case_upload_id)
|
||
if not record:
|
||
return
|
||
record.parse_status = "failed"
|
||
db.session.commit()
|
||
|
||
|
||
# ─── 锁管理 ────────────────────────────────────────────────
|
||
|
||
def _acquire_lock(key: str) -> bool:
|
||
"""获取任务锁(优先 Redis,降级本地内存)。"""
|
||
redis_key = f"poster_task_lock:{key}"
|
||
try:
|
||
from insurance.db.compat import redis_client
|
||
if redis_client and redis_client.set(redis_key, "1", nx=True, ex=1800):
|
||
_redis_locks.add(key)
|
||
return True
|
||
if redis_client:
|
||
return False
|
||
except Exception:
|
||
pass
|
||
|
||
with _local_locks_guard:
|
||
if key in _local_locks:
|
||
return False
|
||
_local_locks.add(key)
|
||
return True
|
||
|
||
|
||
def _release_lock(key: str):
|
||
"""释放任务锁。"""
|
||
if key in _redis_locks:
|
||
try:
|
||
from insurance.db.compat import redis_client
|
||
if redis_client:
|
||
redis_client.delete(f"poster_task_lock:{key}")
|
||
except Exception:
|
||
pass
|
||
_redis_locks.discard(key)
|
||
|
||
with _local_locks_guard:
|
||
_local_locks.discard(key)
|