2026-07-23 15:04:16 +08:00
|
|
|
|
"""海报功能路由。"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
from flask import Blueprint, request, jsonify, send_file
|
2026-08-01 19:38:56 +08:00
|
|
|
|
from insurance.middleware.auth_middleware import account_required as jwt_required
|
2026-07-23 15:04:16 +08:00
|
|
|
|
from insurance.utils.response import success, error, ErrorCode
|
|
|
|
|
|
|
|
|
|
|
|
poster_bp = Blueprint("poster", __name__)
|
|
|
|
|
|
poster_service = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_service():
|
|
|
|
|
|
global poster_service
|
|
|
|
|
|
if poster_service is None:
|
|
|
|
|
|
from insurance.poster.service import PosterService
|
|
|
|
|
|
poster_service = PosterService()
|
|
|
|
|
|
return poster_service
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 12:11:03 +08:00
|
|
|
|
def _user_manual_upload_enabled() -> bool:
|
|
|
|
|
|
from insurance.config import get_config
|
|
|
|
|
|
|
|
|
|
|
|
return get_config("POSTER_USER_MANUAL_UPLOAD_ENABLED", "true").strip().lower() in (
|
|
|
|
|
|
"1", "true", "yes", "on",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:04:16 +08:00
|
|
|
|
# ─── 健康检查 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/health", methods=["GET"])
|
|
|
|
|
|
def health():
|
2026-07-27 13:52:09 +08:00
|
|
|
|
"""海报模块健康检查:reviewed 产品、模板、文案模板、模型配置。"""
|
|
|
|
|
|
from flask import jsonify
|
|
|
|
|
|
checks = {}
|
|
|
|
|
|
critical_missing = []
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from insurance.models.ppt_config import PptProduct
|
|
|
|
|
|
count = PptProduct.query.filter(
|
|
|
|
|
|
PptProduct.manual_parse_status == "reviewed",
|
|
|
|
|
|
PptProduct.status == 1,
|
|
|
|
|
|
).count()
|
|
|
|
|
|
checks["reviewed_products"] = count
|
|
|
|
|
|
if count == 0:
|
|
|
|
|
|
critical_missing.append("reviewed_products")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
checks["reviewed_products"] = f"error: {e}"
|
|
|
|
|
|
critical_missing.append("reviewed_products")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from insurance.models.poster_template_model import PosterTemplate
|
|
|
|
|
|
count = PosterTemplate.query.filter_by(status=1).count()
|
|
|
|
|
|
checks["poster_templates"] = count
|
|
|
|
|
|
if count == 0:
|
|
|
|
|
|
critical_missing.append("poster_templates")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
checks["poster_templates"] = f"error: {e}"
|
|
|
|
|
|
critical_missing.append("poster_templates")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from insurance.models.poster_copy_template import PosterCopyTemplate
|
|
|
|
|
|
count = PosterCopyTemplate.query.filter_by(status=1).count()
|
|
|
|
|
|
checks["copy_templates"] = count
|
|
|
|
|
|
if count == 0:
|
|
|
|
|
|
critical_missing.append("copy_templates")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
checks["copy_templates"] = f"error: {e}"
|
|
|
|
|
|
critical_missing.append("copy_templates")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from insurance.models.system_setting import SystemSetting
|
|
|
|
|
|
keys = ["poster_image_provider", "poster_image_api_key"]
|
|
|
|
|
|
settings = {s.key: s.value for s in SystemSetting.query.filter(SystemSetting.key.in_(keys)).all()}
|
|
|
|
|
|
checks["image_model_configured"] = bool(settings.get("poster_image_api_key"))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
checks["image_model_configured"] = f"error: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
if critical_missing:
|
|
|
|
|
|
checks["status"] = "degraded"
|
|
|
|
|
|
checks["missing"] = critical_missing
|
|
|
|
|
|
return jsonify(checks), 503
|
|
|
|
|
|
|
|
|
|
|
|
checks["status"] = "ok"
|
|
|
|
|
|
return success(checks)
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 产品列表 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/products", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def list_products():
|
|
|
|
|
|
"""获取已 reviewed 的产品列表。"""
|
|
|
|
|
|
return jsonify(_get_service().get_reviewed_products())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 09:50:46 +08:00
|
|
|
|
@poster_bp.route("/product-sources", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def list_product_sources():
|
|
|
|
|
|
"""获取公共产品和当前用户已确认的私有产品资料。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
2026-07-31 12:11:03 +08:00
|
|
|
|
manual_enabled = _user_manual_upload_enabled()
|
2026-07-31 09:50:46 +08:00
|
|
|
|
library_result = _get_service().get_reviewed_products()
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"message": "success",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"libraryProducts": library_result.get("data", []),
|
2026-07-31 12:11:03 +08:00
|
|
|
|
"myMaterials": (
|
|
|
|
|
|
ProductMaterialService().list_available(user_id)
|
|
|
|
|
|
if manual_enabled else []
|
|
|
|
|
|
),
|
|
|
|
|
|
"userManualUploadEnabled": manual_enabled,
|
2026-07-31 09:50:46 +08:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 用户产品小册子 ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def list_product_materials():
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
2026-07-31 12:11:03 +08:00
|
|
|
|
if not _user_manual_upload_enabled():
|
|
|
|
|
|
return jsonify({"code": 0, "data": {"total": 0, "items": []}})
|
2026-07-31 09:50:46 +08:00
|
|
|
|
return jsonify(ProductMaterialService().list_materials(user_id, {
|
|
|
|
|
|
"page": request.args.get("page", 1, type=int),
|
|
|
|
|
|
"page_size": request.args.get("page_size", 50, type=int),
|
|
|
|
|
|
"search": request.args.get("search", ""),
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def upload_product_material():
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
file = request.files.get("file")
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
2026-07-31 12:11:03 +08:00
|
|
|
|
if not _user_manual_upload_enabled():
|
|
|
|
|
|
return error(4108, "用户上传产品小册子功能暂未开放", 403)
|
2026-07-31 09:50:46 +08:00
|
|
|
|
return jsonify(ProductMaterialService().upload(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
file=file,
|
|
|
|
|
|
password=request.form.get("password", ""),
|
|
|
|
|
|
company_id=request.form.get("companyId", ""),
|
|
|
|
|
|
plan_type=request.form.get("planType", ""),
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials/<int:material_id>", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def get_product_material(material_id):
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify(ProductMaterialService().get(material_id, user_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials/<int:material_id>/confirm", methods=["PUT"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def confirm_product_material(material_id):
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify(ProductMaterialService().confirm(
|
|
|
|
|
|
material_id,
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
request.get_json(silent=True) or {},
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials/<int:material_id>/retry", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def retry_product_material(material_id):
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify(ProductMaterialService().retry(material_id, user_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials/<int:material_id>/submit-review", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def submit_product_material_review(material_id):
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify(ProductMaterialService().submit_review(material_id, user_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials/<int:material_id>", methods=["DELETE"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def delete_product_material(material_id):
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify(ProductMaterialService().delete(material_id, user_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/product-materials/<int:material_id>/file", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def get_product_material_file(material_id):
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
from insurance.poster.product_material_service import ProductMaterialService
|
|
|
|
|
|
|
|
|
|
|
|
material, filepath = ProductMaterialService().get_file(material_id, user_id)
|
|
|
|
|
|
if not material:
|
|
|
|
|
|
return error(4106, "产品资料不存在或无权访问", 404)
|
|
|
|
|
|
if not filepath:
|
|
|
|
|
|
return error(4107, "小册子原文件不存在", 404)
|
|
|
|
|
|
return send_file(
|
|
|
|
|
|
filepath,
|
|
|
|
|
|
mimetype="application/pdf",
|
|
|
|
|
|
as_attachment=False,
|
|
|
|
|
|
download_name=material.original_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:04:16 +08:00
|
|
|
|
# ─── 计划书上传 ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/case-upload", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def upload_case():
|
|
|
|
|
|
"""上传计划书 PDF + 触发解析。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
product_id = request.form.get("productId", "")
|
2026-07-31 09:50:46 +08:00
|
|
|
|
product_source = {
|
|
|
|
|
|
"type": request.form.get("productSourceType", "") or (
|
|
|
|
|
|
"library_product" if product_id else ""
|
|
|
|
|
|
),
|
|
|
|
|
|
"id": request.form.get("productSourceId", "") or product_id,
|
|
|
|
|
|
}
|
2026-07-29 15:47:50 +08:00
|
|
|
|
password = request.form.get("password", "")
|
2026-07-23 15:04:16 +08:00
|
|
|
|
file = request.files.get("file")
|
|
|
|
|
|
if not file:
|
|
|
|
|
|
return error(ErrorCode.PARAM_ERROR, "请上传文件")
|
2026-07-31 09:50:46 +08:00
|
|
|
|
return jsonify(_get_service().upload_case(
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
product_id,
|
|
|
|
|
|
file,
|
|
|
|
|
|
password,
|
|
|
|
|
|
product_source=product_source,
|
|
|
|
|
|
))
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/case-upload/<int:record_id>", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def get_case_upload(record_id):
|
|
|
|
|
|
"""获取解析结果。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
return jsonify(_get_service().get_case_upload(record_id, user_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/case-upload/<int:record_id>/confirm", methods=["PUT"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def confirm_case_upload(record_id):
|
|
|
|
|
|
"""核对/修正解析数据。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
return jsonify(_get_service().confirm_case_upload(record_id, user_id, request.get_json(silent=True) or {}))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 01:22:07 +08:00
|
|
|
|
@poster_bp.route("/case-upload/<int:record_id>/reparse", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def reparse_case_upload(record_id):
|
|
|
|
|
|
"""使用当前解析器重新解析已上传的计划书。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
return jsonify(_get_service().reparse_case_upload(record_id, user_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:04:16 +08:00
|
|
|
|
# ─── 模板列表 ─────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-31 21:42:32 +08:00
|
|
|
|
@poster_bp.route("/formats", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def list_formats():
|
|
|
|
|
|
"""获取前后端统一使用的海报输出格式。"""
|
|
|
|
|
|
from insurance.poster.format_registry import list_poster_formats
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify({"code": 0, "message": "success", "data": list_poster_formats()})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:04:16 +08:00
|
|
|
|
@poster_bp.route("/templates", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def list_templates():
|
|
|
|
|
|
"""获取可用海报模板列表。"""
|
|
|
|
|
|
return jsonify(_get_service().get_templates())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/copy-templates", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def list_copy_templates():
|
|
|
|
|
|
"""获取可用文案模板列表。"""
|
|
|
|
|
|
return jsonify(_get_service().get_copy_templates())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 文案生成 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/generate-copy", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def generate_copy():
|
|
|
|
|
|
"""生成文案(template/ai 模式)。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
return jsonify(_get_service().generate_copy(user_id, request.get_json(silent=True) or {}))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 15:20:37 +08:00
|
|
|
|
@poster_bp.route("/compliance-check", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def check_compliance():
|
|
|
|
|
|
"""检查海报文案并返回字符级问题位置。"""
|
|
|
|
|
|
return jsonify(_get_service().check_compliance(request.get_json(silent=True) or {}))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:04:16 +08:00
|
|
|
|
# ─── 海报生成 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/generate", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def generate_poster():
|
|
|
|
|
|
"""生成海报图片。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
return jsonify(_get_service().generate_poster(user_id, request.get_json(silent=True) or {}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 下载海报 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/download/<int:record_id>", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def download_poster(record_id):
|
|
|
|
|
|
"""下载海报文件。"""
|
|
|
|
|
|
from insurance.models.poster_record import PosterRecord
|
文件 问题 严重度
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
|
|
|
|
from insurance.config import get_storage_root
|
2026-07-23 15:04:16 +08:00
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
record = PosterRecord.query.get(record_id)
|
|
|
|
|
|
if not record or record.user_id != user_id:
|
|
|
|
|
|
return error(ErrorCode.NOT_FOUND, "记录不存在")
|
|
|
|
|
|
if not record.export_url:
|
|
|
|
|
|
return error(ErrorCode.NOT_FOUND, "文件不存在")
|
文件 问题 严重度
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
|
|
|
|
# 路径安全校验:仅允许 outputs/posters 目录
|
2026-07-23 15:04:16 +08:00
|
|
|
|
file_abs = os.path.abspath(record.export_url)
|
文件 问题 严重度
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
|
|
|
|
posters_dir = os.path.abspath(os.path.join(get_storage_root(), "outputs", "posters"))
|
2026-07-23 15:04:16 +08:00
|
|
|
|
if not file_abs.startswith(posters_dir) or not os.path.exists(file_abs):
|
|
|
|
|
|
return error(ErrorCode.NOT_FOUND, "文件不存在")
|
|
|
|
|
|
return send_file(file_abs, as_attachment=True,
|
|
|
|
|
|
download_name=f"poster_{record_id}.png", mimetype="image/png")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 记录列表 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/records", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def list_records():
|
|
|
|
|
|
"""当前用户的海报生成记录。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
params = {
|
|
|
|
|
|
"page": request.args.get("page", 1, type=int),
|
|
|
|
|
|
"page_size": request.args.get("page_size", 20, type=int),
|
|
|
|
|
|
}
|
|
|
|
|
|
return jsonify(_get_service().list_records(user_id, params))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/records/<int:record_id>", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def get_record(record_id):
|
|
|
|
|
|
"""记录详情。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
return jsonify(_get_service().get_record(record_id, user_id))
|
2026-07-31 15:20:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/records/<int:record_id>/document", methods=["GET", "PUT"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def poster_document(record_id):
|
|
|
|
|
|
"""读取或保存可编辑海报文档。"""
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
if request.method == "PUT":
|
|
|
|
|
|
payload = request.get_json(silent=True) or {}
|
|
|
|
|
|
return jsonify(_get_service().save_document(
|
|
|
|
|
|
record_id, user_id, payload.get("document") or payload
|
|
|
|
|
|
))
|
|
|
|
|
|
result = _get_service().get_record(record_id, user_id)
|
|
|
|
|
|
if result.get("code") != 0:
|
|
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
record = result["data"]
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"message": "success",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"document": record.get("document"),
|
|
|
|
|
|
"revision": record.get("documentRevision"),
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/records/<int:record_id>/rendered", methods=["POST"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def save_rendered_poster(record_id):
|
|
|
|
|
|
"""保存前端 HTML 画布合成后的最终 PNG。"""
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
try:
|
|
|
|
|
|
document = json.loads(request.form.get("document") or "{}")
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
return error(ErrorCode.PARAM_ERROR, "海报文档格式错误")
|
|
|
|
|
|
return jsonify(_get_service().save_rendered(
|
|
|
|
|
|
record_id, user_id, request.files.get("file"), document
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@poster_bp.route("/records/<int:record_id>/background", methods=["GET"])
|
|
|
|
|
|
@jwt_required
|
|
|
|
|
|
def get_poster_background(record_id):
|
|
|
|
|
|
"""读取 AI 背景资产;兼容迁移前将背景写入 export_url 的记录。"""
|
|
|
|
|
|
from insurance.models.poster_record import PosterRecord
|
|
|
|
|
|
from insurance.config import get_storage_root
|
|
|
|
|
|
|
|
|
|
|
|
user_id = str(getattr(request, "user_id", "guest"))
|
|
|
|
|
|
record = PosterRecord.query.get(record_id)
|
|
|
|
|
|
if not record or record.user_id != user_id:
|
|
|
|
|
|
return error(ErrorCode.NOT_FOUND, "记录不存在")
|
|
|
|
|
|
filepath = record.background_file_url or record.export_url
|
|
|
|
|
|
file_abs = os.path.abspath(filepath or "")
|
|
|
|
|
|
posters_dir = os.path.abspath(os.path.join(get_storage_root(), "outputs", "posters"))
|
|
|
|
|
|
if not file_abs.startswith(posters_dir) or not os.path.exists(file_abs):
|
|
|
|
|
|
return error(ErrorCode.NOT_FOUND, "背景文件不存在")
|
|
|
|
|
|
return send_file(file_abs, as_attachment=False, mimetype="image/png")
|