baodan/api/insurance/poster/routes.py
wsb1224 d57bd59acd 主要改动:
PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470)
补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。
IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741)
PPT 缓存升级到 v6,旧错误缓存会自动失效。
提领方案不再与普通退保价值混淆,相关提示语已统一。
海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51)
系统计算、利益表派生、人工修改增加来源标记。
增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266)
ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。
PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195)
验证结果:
核心专项测试:145 passed
除既有聊天日志测试外的测试集:236 passed
前端生产构建:通过
Python 语法检查:通过
git diff --check:通过
Impeccable 前端检测:无发现
全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00

426 lines
16 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("/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))
# ─── 模板列表 ─────────────────────────────────────────────
@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()})
@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")