baodan/api/insurance/poster/routes.py
wsb1224 cb09ed4803 已按计划书完成本轮核心 P0/P1 修复,覆盖用户反馈的三个主要问题。
主要结果:
PPT:
已完成步骤可自由返回,刷新后保留当前步骤。
结果页改为固定三栏视口,缩略图和质量面板独立滚动。
修复画布宽度计算和宽高自适应缩放。
返回配置页时恢复上次模板。
Worker 禁止静默替换模板,不兼容时明确失败。
结果页显示实际应用的模板。

海报解析与合规:
修复 insured/policy 嵌套字段映射。
增加 partial/failed 解析质量判断和缺失字段提示。
传入险种、产品、保司和别名上下文。
修复性别、币种归一化和错误字段计数。
新增服务端字符级合规接口与生成前复检。
不合规文字可高亮、点击定位并选中对应文字。

海报编辑:
AI 图片现在作为背景资产,不再替换整个海报。
单图、长图生成后仍可编辑标题、正文、CTA、数据和卖点。
背景生成失败不会清空当前编辑内容。
下载时合成背景、文字、数据、图表和免责声明。
增加可编辑文档快照、最终 PNG 保存和历史继续编辑。
新增数据库迁移:[migrate_028.py](/D:/work/code/python/coding/baodanagent/api/insurance/db/migrate_028.py)

验证结果:
专项及相关后端测试:26 passed, 1 skipped
Python 全模块编译检查:通过
前端生产构建:通过
git diff --check:通过
全量测试收集受本机缺少 python-pptx 依赖影响,报错为 ModuleNotFoundError: pptx,不是本次修改产生的测试失败。
2026-07-31 15:20:37 +08:00

409 lines
15 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 os
from flask import Blueprint, request, jsonify, send_file
from insurance.middleware.auth_middleware import jwt_required
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
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",
)
# ─── 健康检查 ─────────────────────────────────────────────
@poster_bp.route("/health", methods=["GET"])
def health():
"""海报模块健康检查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)
# ─── 产品列表 ─────────────────────────────────────────────
@poster_bp.route("/products", methods=["GET"])
@jwt_required
def list_products():
"""获取已 reviewed 的产品列表。"""
return jsonify(_get_service().get_reviewed_products())
@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
manual_enabled = _user_manual_upload_enabled()
library_result = _get_service().get_reviewed_products()
return jsonify({
"code": 0,
"message": "success",
"data": {
"libraryProducts": library_result.get("data", []),
"myMaterials": (
ProductMaterialService().list_available(user_id)
if manual_enabled else []
),
"userManualUploadEnabled": manual_enabled,
},
})
# ─── 用户产品小册子 ───────────────────────────────────────
@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
if not _user_manual_upload_enabled():
return jsonify({"code": 0, "data": {"total": 0, "items": []}})
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
if not _user_manual_upload_enabled():
return error(4108, "用户上传产品小册子功能暂未开放", 403)
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,
)
# ─── 计划书上传 ───────────────────────────────────────────
@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", "")
product_source = {
"type": request.form.get("productSourceType", "") or (
"library_product" if product_id else ""
),
"id": request.form.get("productSourceId", "") or product_id,
}
password = request.form.get("password", "")
file = request.files.get("file")
if not file:
return error(ErrorCode.PARAM_ERROR, "请上传文件")
return jsonify(_get_service().upload_case(
user_id,
product_id,
file,
password,
product_source=product_source,
))
@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 {}))
# ─── 模板列表 ─────────────────────────────────────────────
@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 {}))
@poster_bp.route("/compliance-check", methods=["POST"])
@jwt_required
def check_compliance():
"""检查海报文案并返回字符级问题位置。"""
return jsonify(_get_service().check_compliance(request.get_json(silent=True) or {}))
# ─── 海报生成 ─────────────────────────────────────────────
@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
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, "记录不存在")
if not record.export_url:
return error(ErrorCode.NOT_FOUND, "文件不存在")
# 路径安全校验:仅允许 outputs/posters 目录
file_abs = os.path.abspath(record.export_url)
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=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))
@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")