baodan/api/insurance/generation/task_service.py
wsb1224 751302205a 文件 问题 严重度
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 端点
2026-07-29 22:41:27 +08:00

242 lines
8.8 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.

"""统一任务服务:创建、查询、取消任务。"""
import json
import logging
import uuid
from datetime import datetime
logger = logging.getLogger(__name__)
def create_task(user_id: str, artifact_type: str, operation: str,
workspace_id: str, title: str = "",
input_snapshot: dict = None, idempotency_key: str = None) -> dict:
"""创建生成任务并提交到 Celery。
返回 {"code": 0, "data": task_dict} 或错误。
"""
from insurance.db.compat import db
from insurance.models.generation_task import GenerationTask
# 幂等检查
if idempotency_key:
existing = GenerationTask.query.filter_by(
idempotency_key=idempotency_key,
).filter(GenerationTask.status.in_(["queued", "running", "done"])).first()
if existing:
return {"code": 0, "data": existing.to_dict(), "message": "任务已存在"}
# 检查同一工作区是否有运行中的任务
active = GenerationTask.query.filter_by(
workspace_id=workspace_id,
).filter(GenerationTask.status.in_(["queued", "running"])).first()
if active:
return {"code": 1001, "message": "该工作区有正在执行的任务,请等待完成", "data": None}
task = GenerationTask(
id=uuid.uuid4().hex,
user_id=user_id,
artifact_type=artifact_type,
operation=operation,
workspace_id=workspace_id,
title_snapshot=title,
input_snapshot_json=json.dumps(input_snapshot, ensure_ascii=False) if input_snapshot else None,
idempotency_key=idempotency_key,
)
db.session.add(task)
db.session.commit()
# 提交到 Celery。投递失败时必须结束数据库任务不能留下永久 queued。
try:
_dispatch_to_celery(task)
except Exception as exc:
message = f"任务提交失败: {exc}"
logger.error("任务 %s 提交到 Celery 失败: %s", task.id, exc, exc_info=True)
task.status = "failed"
task.error_code = "dispatch_failed"
task.error_message = message[:1000]
task.finished_at = datetime.now()
db.session.commit()
_sync_failed_ppt_session(task, message)
return {"code": 9999, "message": message, "data": task.to_dict()}
return {"code": 0, "data": task.to_dict()}
def _dispatch_to_celery(task):
"""根据任务类型分发到对应的 Celery 任务。"""
from insurance.generation.celery_tasks import (
parse_ppt_task, generate_ppt_task, regenerate_ppt_task,
generate_poster_task,
)
task_map = {
("ppt", "parse"): parse_ppt_task,
("ppt", "generate"): generate_ppt_task,
("ppt", "regenerate"): regenerate_ppt_task,
("poster", "generate"): generate_poster_task,
}
celery_task_fn = task_map.get((task.artifact_type, task.operation))
if not celery_task_fn:
logger.error(f"未知任务类型: {task.artifact_type}/{task.operation}")
task.status = "failed"
task.error_code = "unknown_type"
task.error_message = f"未知任务类型: {task.artifact_type}/{task.operation}"
from insurance.db.compat import db
db.session.commit()
return
result = celery_task_fn.apply_async(args=[task.id], queue="insurance")
task.celery_task_id = result.id
from insurance.db.compat import db
db.session.commit()
logger.info(f"任务 {task.id} 已提交到 Celery: {result.id}")
def _sync_failed_ppt_session(task, message: str):
"""将 PPT 解析任务失败同步到工作区,避免页面永久停在 parsing。"""
if task.artifact_type != "ppt" or task.operation != "parse":
return
from insurance.db.compat import db
from insurance.models.ppt_session import PptSession
session = PptSession.query.get(task.workspace_id)
if not session or session.status != "parsing":
return
if session.latest_task_id and session.latest_task_id != task.id:
return
session.status = "error"
session.parse_progress = 100
session.parse_message = "处理失败"
session.parse_error = message[:1000]
session.parse_finished_at = datetime.now()
db.session.commit()
def list_active_tasks(user_id: str, artifact_type: str = None) -> list:
"""查询用户活跃任务(任务坞用)。"""
from insurance.models.generation_task import GenerationTask
query = GenerationTask.query.filter_by(user_id=user_id)
query = query.filter(GenerationTask.dock_hidden_at.is_(None))
if artifact_type:
query = query.filter_by(artifact_type=artifact_type)
# 只返回活跃和最近完成的任务
query = query.filter(
GenerationTask.status.in_(["queued", "running", "done", "failed"])
)
query = query.order_by(GenerationTask.created_at.desc())
tasks = query.limit(20).all()
return [t.to_dict() for t in tasks]
def list_tasks(user_id: str, artifact_type: str = None,
status: str = None, page: int = 1, page_size: int = 20) -> dict:
"""查询任务列表(任务中心用)。"""
from insurance.models.generation_task import GenerationTask
query = GenerationTask.query.filter_by(user_id=user_id)
if artifact_type:
query = query.filter_by(artifact_type=artifact_type)
if status:
query = query.filter_by(status=status)
query = query.order_by(GenerationTask.created_at.desc())
total = query.count()
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"items": [t.to_dict() for t in items],
}
def get_task(task_id: str, user_id: str) -> dict:
"""获取任务详情。"""
from insurance.models.generation_task import GenerationTask
task = GenerationTask.query.get(task_id)
if not task or task.user_id != user_id:
return {"code": 1002, "message": "任务不存在", "data": None}
return {"code": 0, "data": task.to_dict()}
def cancel_task(task_id: str, user_id: str) -> dict:
"""取消排队中的任务。"""
from insurance.db.compat import db
from insurance.models.generation_task import GenerationTask
task = GenerationTask.query.get(task_id)
if not task or task.user_id != user_id:
return {"code": 1002, "message": "任务不存在", "data": None}
if task.status != "queued":
return {"code": 1001, "message": "只能取消排队中的任务", "data": None}
task.status = "cancelled"
task.finished_at = datetime.now()
db.session.commit()
return {"code": 0, "data": task.to_dict()}
def hide_task_from_dock(task_id: str, user_id: str) -> dict:
"""从任务坞隐藏任务。"""
from insurance.db.compat import db
from insurance.models.generation_task import GenerationTask
task = GenerationTask.query.get(task_id)
if not task or task.user_id != user_id:
return {"code": 1002, "message": "任务不存在", "data": None}
task.dock_hidden_at = datetime.now()
db.session.commit()
return {"code": 0, "data": task.to_dict()}
# ─── 过期任务恢复 ──────────────────────────────────────────
STALE_TASK_TIMEOUT = 600 # 10 分钟无心跳视为过期
def recover_stale_tasks():
"""启动时恢复过期任务:将长时间无心跳的 running 任务标记为 failed。
应在应用启动时调用。
"""
from insurance.db.compat import db
from insurance.models.generation_task import GenerationTask
cutoff = datetime.now().timestamp() - STALE_TASK_TIMEOUT
# 恢复无心跳的 running 任务
stale_running = GenerationTask.query.filter(
GenerationTask.status == "running",
GenerationTask.heartbeat_at < datetime.fromtimestamp(cutoff),
).all()
for task in stale_running:
task.status = "failed"
task.error_code = "stale"
task.error_message = "任务因服务重启而中断,请重新提交"
task.finished_at = datetime.now()
_sync_failed_ppt_session(task, task.error_message)
logger.warning(f"恢复过期任务: {task.id} (workspace={task.workspace_id})")
# 恢复长时间 queued 的任务(可能 Celery 未消费)
stale_queued = GenerationTask.query.filter(
GenerationTask.status == "queued",
GenerationTask.created_at < datetime.fromtimestamp(cutoff),
).all()
for task in stale_queued:
task.status = "failed"
task.error_code = "queue_timeout"
task.error_message = "任务排队超时,请重新提交"
task.finished_at = datetime.now()
_sync_failed_ppt_session(task, task.error_message)
logger.warning(f"恢复排队超时任务: {task.id}")
if stale_running or stale_queued:
db.session.commit()
logger.info(f"已恢复 {len(stale_running)} 个运行过期任务, {len(stale_queued)} 个排队超时任务")