2026-07-23 15:04:16 +08:00
|
|
|
|
"""海报业务逻辑服务。"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from flask import current_app
|
|
|
|
|
|
from insurance.db.compat import db
|
|
|
|
|
|
from insurance.models.ppt_config import PptProduct, PptCompany
|
|
|
|
|
|
from insurance.models.poster_case_upload import PosterCaseUpload
|
|
|
|
|
|
from insurance.models.poster_template_model import PosterTemplate
|
|
|
|
|
|
from insurance.models.poster_copy_template import PosterCopyTemplate
|
|
|
|
|
|
from insurance.models.poster_record import PosterRecord
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_user_id(user_id: str) -> str:
|
|
|
|
|
|
"""清理 user_id 中的路径分隔符,防止路径穿越。"""
|
|
|
|
|
|
return re.sub(r'[/\\.]', '_', user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_async(coro):
|
|
|
|
|
|
"""安全执行异步函数,处理事件循环已存在的情况。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
|
loop = None
|
|
|
|
|
|
if loop and loop.is_running():
|
|
|
|
|
|
# 已有运行中的事件循环,用线程池执行
|
|
|
|
|
|
import concurrent.futures
|
|
|
|
|
|
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
|
|
|
|
return pool.submit(asyncio.run, coro).result()
|
|
|
|
|
|
return asyncio.run(coro)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PosterService:
|
|
|
|
|
|
"""海报业务逻辑。"""
|
|
|
|
|
|
|
|
|
|
|
|
def get_reviewed_products(self) -> dict:
|
|
|
|
|
|
"""获取已 reviewed 的产品列表(供选择)。"""
|
|
|
|
|
|
products = PptProduct.query.filter(
|
|
|
|
|
|
PptProduct.manual_parse_status == "reviewed",
|
|
|
|
|
|
PptProduct.status == 1,
|
|
|
|
|
|
).all()
|
文件 问题 严重度
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
|
|
|
|
if not products:
|
|
|
|
|
|
return {"code": 0, "data": []}
|
|
|
|
|
|
# 批量查询公司(避免 N+1)
|
|
|
|
|
|
company_ids = list({p.company_id for p in products})
|
|
|
|
|
|
companies = {
|
|
|
|
|
|
c.id: c for c in PptCompany.query.filter(
|
|
|
|
|
|
PptCompany.id.in_(company_ids),
|
|
|
|
|
|
PptCompany.status == 1,
|
|
|
|
|
|
).all()
|
|
|
|
|
|
}
|
2026-07-23 15:04:16 +08:00
|
|
|
|
result = []
|
|
|
|
|
|
for p in products:
|
文件 问题 严重度
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
|
|
|
|
company = companies.get(p.company_id)
|
2026-07-29 12:19:26 +08:00
|
|
|
|
if not company:
|
|
|
|
|
|
continue
|
2026-07-23 15:04:16 +08:00
|
|
|
|
result.append({
|
|
|
|
|
|
**p.to_dict(),
|
2026-07-29 12:19:26 +08:00
|
|
|
|
"companyName": company.display_name,
|
2026-07-23 15:04:16 +08:00
|
|
|
|
})
|
|
|
|
|
|
return {"code": 0, "data": result}
|
|
|
|
|
|
|
2026-07-29 15:47:50 +08:00
|
|
|
|
def upload_case(self, user_id: str, product_id: str, file, password: str = "") -> dict:
|
2026-07-27 13:52:09 +08:00
|
|
|
|
"""上传计划书 PDF + 排队解析(异步)。"""
|
2026-07-29 12:19:26 +08:00
|
|
|
|
product = PptProduct.query.filter_by(
|
|
|
|
|
|
id=product_id, status=1, manual_parse_status="reviewed"
|
|
|
|
|
|
).first()
|
2026-07-23 15:04:16 +08:00
|
|
|
|
if not product:
|
|
|
|
|
|
return {"code": 1002, "message": "产品不存在", "data": None}
|
|
|
|
|
|
|
2026-07-27 13:52:09 +08:00
|
|
|
|
# 文件安全校验(SEC-P1-01)
|
2026-07-29 15:47:50 +08:00
|
|
|
|
from insurance.utils.security import prepare_pdf_upload
|
2026-07-29 12:19:26 +08:00
|
|
|
|
if not PptCompany.query.filter_by(id=product.company_id, status=1).first():
|
|
|
|
|
|
return {"code": 1002, "message": "产品所属保司不存在或已停用", "data": None}
|
|
|
|
|
|
|
2026-07-29 15:47:50 +08:00
|
|
|
|
is_valid, err_msg, pdf_bytes = prepare_pdf_upload(file, password)
|
2026-07-27 13:52:09 +08:00
|
|
|
|
if not is_valid:
|
2026-07-29 15:47:50 +08:00
|
|
|
|
code = 4003 if "密码" in err_msg else 4002
|
|
|
|
|
|
return {"code": code, "message": err_msg, "data": None}
|
2026-07-27 13:52:09 +08:00
|
|
|
|
|
|
|
|
|
|
# 保存文件(使用持久化存储)
|
2026-07-23 15:04:16 +08:00
|
|
|
|
safe_uid = _safe_user_id(user_id)
|
2026-07-27 13:52:09 +08:00
|
|
|
|
from insurance.config import get_storage_root
|
|
|
|
|
|
upload_dir = os.path.join(get_storage_root(), "uploads", "poster-cases")
|
2026-07-23 15:04:16 +08:00
|
|
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
|
|
|
|
filename = f"{safe_uid}_{uuid.uuid4().hex[:8]}.pdf"
|
|
|
|
|
|
filepath = os.path.join(upload_dir, filename)
|
2026-07-29 15:47:50 +08:00
|
|
|
|
with open(filepath, "wb") as output:
|
|
|
|
|
|
output.write(pdf_bytes)
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
|
|
|
|
|
# 创建记录
|
|
|
|
|
|
record = PosterCaseUpload(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
product_id=product_id,
|
|
|
|
|
|
source_file_url=filepath,
|
|
|
|
|
|
parse_status="pending",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(record)
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
2026-07-27 13:52:09 +08:00
|
|
|
|
# 启动后台解析任务
|
|
|
|
|
|
from insurance.poster.tasks import start_case_parse_task
|
|
|
|
|
|
if start_case_parse_task(current_app._get_current_object(), record.id):
|
|
|
|
|
|
record.parse_status = "queued"
|
2026-07-23 15:04:16 +08:00
|
|
|
|
db.session.commit()
|
2026-07-27 13:52:09 +08:00
|
|
|
|
else:
|
2026-07-23 15:04:16 +08:00
|
|
|
|
record.parse_status = "failed"
|
|
|
|
|
|
db.session.commit()
|
2026-07-27 13:52:09 +08:00
|
|
|
|
return {"code": 5001, "message": "任务排队失败,请重试", "data": None}
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
|
|
|
|
|
return {"code": 0, "data": record.to_dict()}
|
|
|
|
|
|
|
|
|
|
|
|
def get_case_upload(self, record_id: int, user_id: str) -> dict:
|
|
|
|
|
|
"""获取解析结果。"""
|
|
|
|
|
|
record = PosterCaseUpload.query.get(record_id)
|
|
|
|
|
|
if not record or record.user_id != user_id:
|
|
|
|
|
|
return {"code": 1002, "message": "记录不存在", "data": None}
|
|
|
|
|
|
return {"code": 0, "data": record.to_dict()}
|
|
|
|
|
|
|
|
|
|
|
|
def confirm_case_upload(self, record_id: int, user_id: str, data: dict) -> dict:
|
|
|
|
|
|
"""人工核对/修正解析数据。"""
|
|
|
|
|
|
record = PosterCaseUpload.query.get(record_id)
|
|
|
|
|
|
if not record or record.user_id != user_id:
|
|
|
|
|
|
return {"code": 1002, "message": "记录不存在", "data": None}
|
|
|
|
|
|
record.confirmed_data = json.dumps(data.get("confirmedData", {}), ensure_ascii=False)
|
|
|
|
|
|
record.confirmed_by = user_id
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
record.confirmed_at = datetime.now()
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
return {"code": 0, "data": record.to_dict()}
|
|
|
|
|
|
|
|
|
|
|
|
def get_templates(self) -> dict:
|
|
|
|
|
|
"""获取可用海报模板列表。"""
|
2026-07-29 12:19:26 +08:00
|
|
|
|
templates = PosterTemplate.query.filter_by(status=1).order_by(
|
|
|
|
|
|
PosterTemplate.id.asc()
|
|
|
|
|
|
).all()
|
2026-07-23 15:04:16 +08:00
|
|
|
|
return {"code": 0, "data": [t.to_dict() for t in templates]}
|
|
|
|
|
|
|
|
|
|
|
|
def get_copy_templates(self) -> dict:
|
|
|
|
|
|
"""获取可用文案模板列表。"""
|
2026-07-29 12:19:26 +08:00
|
|
|
|
templates = PosterCopyTemplate.query.filter_by(status=1).order_by(
|
|
|
|
|
|
PosterCopyTemplate.id.asc()
|
|
|
|
|
|
).all()
|
2026-07-23 15:04:16 +08:00
|
|
|
|
return {"code": 0, "data": [t.to_dict() for t in templates]}
|
|
|
|
|
|
|
|
|
|
|
|
def generate_copy(self, user_id: str, data: dict) -> dict:
|
|
|
|
|
|
"""生成文案(template/ai 模式)。"""
|
|
|
|
|
|
mode = data.get("mode", "template")
|
|
|
|
|
|
case_upload_id = data.get("caseUploadId")
|
|
|
|
|
|
product_id = data.get("productId")
|
2026-07-28 16:45:14 +08:00
|
|
|
|
use_masked_data = bool(data.get("useMaskedData"))
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
|
|
|
|
|
# 获取产品规则
|
|
|
|
|
|
product_rules = {}
|
2026-07-29 12:19:26 +08:00
|
|
|
|
product = None
|
2026-07-23 15:04:16 +08:00
|
|
|
|
if product_id:
|
2026-07-29 12:19:26 +08:00
|
|
|
|
product = PptProduct.query.filter_by(
|
|
|
|
|
|
id=product_id, status=1, manual_parse_status="reviewed"
|
|
|
|
|
|
).first()
|
|
|
|
|
|
if not product:
|
|
|
|
|
|
return {"code": 1002, "message": "产品不存在、未启用或尚未审核", "data": None}
|
2026-07-23 15:04:16 +08:00
|
|
|
|
if product and product.manual_parsed_rules:
|
文件 问题 严重度
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
|
|
|
|
try:
|
|
|
|
|
|
product_rules = json.loads(product.manual_parsed_rules)
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
logger.warning("产品规则 JSON 解析失败: product_id=%s", product_id)
|
|
|
|
|
|
product_rules = {}
|
2026-07-29 12:19:26 +08:00
|
|
|
|
product_data = product.to_dict()
|
|
|
|
|
|
product_rules.setdefault("product_name", product.display_name)
|
|
|
|
|
|
product_rules.setdefault("coverage_period", product.coverage_period or "")
|
|
|
|
|
|
product_rules.setdefault("payment_period", product.payment_period or "")
|
|
|
|
|
|
product_rules.setdefault("insured_age_range", product.insured_age_range or "")
|
|
|
|
|
|
if not product_rules.get("features") and product_data.get("highlights"):
|
|
|
|
|
|
product_rules["features"] = [
|
|
|
|
|
|
{"title": item, "summary": ""}
|
|
|
|
|
|
if isinstance(item, str) else item
|
|
|
|
|
|
for item in product_data["highlights"]
|
|
|
|
|
|
]
|
|
|
|
|
|
company = PptCompany.query.filter_by(
|
|
|
|
|
|
id=product.company_id, status=1
|
|
|
|
|
|
).first()
|
|
|
|
|
|
if not company:
|
|
|
|
|
|
return {"code": 1002, "message": "产品所属保司不存在或已停用", "data": None}
|
|
|
|
|
|
product_rules.setdefault("company_name", company.display_name)
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
2026-07-28 16:45:14 +08:00
|
|
|
|
# 脱敏处理:替换产品规则中的产品名和保司名
|
|
|
|
|
|
if use_masked_data and product_rules:
|
|
|
|
|
|
from insurance.ppt.masking import (
|
|
|
|
|
|
apply_product_mask, apply_company_mask,
|
|
|
|
|
|
fallback_mask_name, mask_text, build_name_replacements,
|
|
|
|
|
|
)
|
|
|
|
|
|
if product:
|
|
|
|
|
|
product_dict = product.to_dict()
|
|
|
|
|
|
apply_product_mask(product_dict, True)
|
|
|
|
|
|
product_rules["product_name"] = product_dict.get("displayName", product_rules.get("product_name", ""))
|
|
|
|
|
|
# 替换规则中的产品名引用
|
|
|
|
|
|
masked_name = product_rules["product_name"]
|
|
|
|
|
|
real_name = product.display_name
|
|
|
|
|
|
if real_name and masked_name and real_name != masked_name:
|
|
|
|
|
|
for key in product_rules:
|
|
|
|
|
|
if isinstance(product_rules[key], str):
|
|
|
|
|
|
product_rules[key] = product_rules[key].replace(real_name, masked_name)
|
|
|
|
|
|
# 也替换保司名
|
|
|
|
|
|
company = PptCompany.query.get(product.company_id)
|
|
|
|
|
|
if company:
|
|
|
|
|
|
company_dict = company.to_dict()
|
|
|
|
|
|
apply_company_mask(company_dict, True)
|
|
|
|
|
|
masked_company = company_dict.get("displayName", "")
|
|
|
|
|
|
real_company = company.display_name
|
|
|
|
|
|
if real_company and masked_company and real_company != masked_company:
|
|
|
|
|
|
for key in product_rules:
|
|
|
|
|
|
if isinstance(product_rules[key], str):
|
|
|
|
|
|
product_rules[key] = product_rules[key].replace(real_company, masked_company)
|
|
|
|
|
|
|
2026-07-27 13:52:09 +08:00
|
|
|
|
# 获取客户数据(校验 case 所有权 — 防止越权读取他人客户数据)
|
2026-07-23 15:04:16 +08:00
|
|
|
|
customer_data = {}
|
|
|
|
|
|
if case_upload_id:
|
|
|
|
|
|
case = PosterCaseUpload.query.get(case_upload_id)
|
2026-07-27 13:52:09 +08:00
|
|
|
|
if not case or case.user_id != user_id:
|
|
|
|
|
|
return {"code": 404, "message": "记录不存在", "data": None}
|
2026-07-29 12:19:26 +08:00
|
|
|
|
if product_id and case.product_id != product_id:
|
|
|
|
|
|
return {"code": 1002, "message": "计划书与所选产品不一致", "data": None}
|
2026-07-27 13:52:09 +08:00
|
|
|
|
if case.confirmed_data:
|
文件 问题 严重度
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
|
|
|
|
try:
|
|
|
|
|
|
customer_data = json.loads(case.confirmed_data)
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
logger.warning("confirmed_data JSON 解析失败: case_id=%s", case_upload_id)
|
|
|
|
|
|
customer_data = {}
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
|
|
|
|
|
if mode == "template":
|
|
|
|
|
|
template_id = data.get("templateId")
|
2026-07-29 12:19:26 +08:00
|
|
|
|
template = PosterCopyTemplate.query.filter_by(
|
|
|
|
|
|
id=template_id, status=1
|
|
|
|
|
|
).first()
|
2026-07-23 15:04:16 +08:00
|
|
|
|
if not template:
|
|
|
|
|
|
return {"code": 1002, "message": "文案模板不存在", "data": None}
|
|
|
|
|
|
from insurance.poster.copy_generator import CopyGenerator
|
|
|
|
|
|
generator = CopyGenerator()
|
|
|
|
|
|
result = generator.generate_template_copy(template.content, product_rules, customer_data)
|
|
|
|
|
|
return {"code": 0, "data": {"copy": result, "mode": "template"}}
|
|
|
|
|
|
else:
|
|
|
|
|
|
style = data.get("style", "专业")
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
from insurance.poster.copy_generator import CopyGenerator
|
|
|
|
|
|
generator = CopyGenerator()
|
|
|
|
|
|
result = _run_async(generator.generate_ai_copy(product_rules, customer_data, style))
|
|
|
|
|
|
return {"code": 0, "data": {"copy": result, "mode": "ai"}}
|
|
|
|
|
|
|
|
|
|
|
|
def generate_poster(self, user_id: str, data: dict) -> dict:
|
2026-07-27 13:52:09 +08:00
|
|
|
|
"""生成海报图片(异步 — 排队后立即返回)。"""
|
2026-07-23 15:04:16 +08:00
|
|
|
|
case_upload_id = data.get("caseUploadId")
|
|
|
|
|
|
template_id = data.get("templateId")
|
|
|
|
|
|
copy_content = data.get("copyContent", {})
|
|
|
|
|
|
size = data.get("size", "1024x1792")
|
|
|
|
|
|
product_id = data.get("productId")
|
|
|
|
|
|
reference_image = data.get("referenceImage")
|
2026-07-27 13:52:09 +08:00
|
|
|
|
force_regenerate = data.get("regenerate", False)
|
2026-07-28 16:45:14 +08:00
|
|
|
|
use_masked_data = bool(data.get("useMaskedData"))
|
2026-07-23 15:04:16 +08:00
|
|
|
|
|
2026-07-29 12:19:26 +08:00
|
|
|
|
template = PosterTemplate.query.filter_by(id=template_id, status=1).first()
|
|
|
|
|
|
if not template:
|
|
|
|
|
|
return {"code": 1002, "message": "海报模板不存在或已停用", "data": None}
|
|
|
|
|
|
product = PptProduct.query.filter_by(
|
|
|
|
|
|
id=product_id, status=1, manual_parse_status="reviewed"
|
|
|
|
|
|
).first()
|
|
|
|
|
|
if not product:
|
|
|
|
|
|
return {"code": 1002, "message": "产品不存在、未启用或尚未审核", "data": None}
|
|
|
|
|
|
company = PptCompany.query.filter_by(id=product.company_id, status=1).first()
|
|
|
|
|
|
if not company:
|
|
|
|
|
|
return {"code": 1002, "message": "产品所属保司不存在或已停用", "data": None}
|
|
|
|
|
|
|
2026-07-27 13:21:34 +08:00
|
|
|
|
# 校验 case 所有权(SEC-P0-03)
|
|
|
|
|
|
if case_upload_id:
|
|
|
|
|
|
case = PosterCaseUpload.query.get(case_upload_id)
|
|
|
|
|
|
if not case or case.user_id != user_id:
|
|
|
|
|
|
return {"code": 404, "message": "记录不存在", "data": None}
|
2026-07-29 12:19:26 +08:00
|
|
|
|
if case.product_id != product_id:
|
|
|
|
|
|
return {"code": 1002, "message": "计划书与所选产品不一致", "data": None}
|
2026-07-27 13:21:34 +08:00
|
|
|
|
if case.confirmed_data is None:
|
|
|
|
|
|
return {"code": 1002, "message": "请先确认解析数据", "data": None}
|
|
|
|
|
|
|
2026-07-27 13:52:09 +08:00
|
|
|
|
# 幂等检查(TASK-P1-02):相同参数的未完成任务直接返回
|
|
|
|
|
|
if not force_regenerate and case_upload_id:
|
|
|
|
|
|
existing = PosterRecord.query.filter(
|
|
|
|
|
|
PosterRecord.user_id == user_id,
|
|
|
|
|
|
PosterRecord.case_upload_id == case_upload_id,
|
|
|
|
|
|
PosterRecord.template_id == template_id,
|
|
|
|
|
|
PosterRecord.task_status.in_(["pending", "queued", "generating"]),
|
|
|
|
|
|
).order_by(PosterRecord.created_at.desc()).first()
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
return {"code": 0, "data": existing.to_dict()}
|
|
|
|
|
|
|
|
|
|
|
|
# 创建记录(任务状态为 pending)
|
2026-07-23 15:04:16 +08:00
|
|
|
|
ai_raw_content = data.get("aiRawContent")
|
|
|
|
|
|
record = PosterRecord(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
product_id=product_id,
|
|
|
|
|
|
case_upload_id=case_upload_id,
|
|
|
|
|
|
template_id=template_id,
|
|
|
|
|
|
copy_mode=data.get("copyMode", "ai"),
|
|
|
|
|
|
copy_content=json.dumps(copy_content, ensure_ascii=False) if copy_content else None,
|
|
|
|
|
|
ai_raw_content=json.dumps(ai_raw_content, ensure_ascii=False) if ai_raw_content else None,
|
|
|
|
|
|
export_size=size,
|
|
|
|
|
|
reference_image_used=reference_image,
|
2026-07-27 13:52:09 +08:00
|
|
|
|
task_status="pending",
|
|
|
|
|
|
task_progress=0,
|
2026-07-28 16:45:14 +08:00
|
|
|
|
extra_data=json.dumps({"useMaskedData": use_masked_data}, ensure_ascii=False),
|
2026-07-23 15:04:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.session.add(record)
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
2026-07-28 17:53:14 +08:00
|
|
|
|
# 启动后台生成任务(Celery)
|
|
|
|
|
|
from insurance.generation import task_service
|
|
|
|
|
|
task_result = task_service.create_task(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
artifact_type="poster",
|
|
|
|
|
|
operation="generate",
|
|
|
|
|
|
workspace_id=str(record.id),
|
|
|
|
|
|
title=f"海报 #{record.id}",
|
|
|
|
|
|
input_snapshot=data,
|
|
|
|
|
|
idempotency_key=f"poster_gen_{record.id}",
|
|
|
|
|
|
)
|
|
|
|
|
|
if task_result.get("code") == 0:
|
2026-07-27 13:52:09 +08:00
|
|
|
|
record.task_status = "queued"
|
2026-07-28 17:53:14 +08:00
|
|
|
|
record.latest_task_id = task_result["data"]["id"]
|
2026-07-27 13:52:09 +08:00
|
|
|
|
db.session.commit()
|
|
|
|
|
|
else:
|
|
|
|
|
|
record.task_status = "failed"
|
2026-07-28 17:53:14 +08:00
|
|
|
|
record.task_error = task_result.get("message", "任务排队失败")
|
2026-07-27 13:52:09 +08:00
|
|
|
|
db.session.commit()
|
|
|
|
|
|
return {"code": 5001, "message": "任务排队失败,请重试", "data": None}
|
|
|
|
|
|
|
2026-07-23 15:04:16 +08:00
|
|
|
|
return {"code": 0, "data": record.to_dict()}
|
|
|
|
|
|
|
|
|
|
|
|
def list_records(self, user_id: str, params: dict) -> dict:
|
|
|
|
|
|
"""海报生成记录列表。"""
|
|
|
|
|
|
query = db.session.query(PosterRecord).filter(PosterRecord.user_id == user_id)
|
|
|
|
|
|
query = query.order_by(PosterRecord.created_at.desc())
|
|
|
|
|
|
page = max(1, params.get("page", 1))
|
|
|
|
|
|
page_size = min(100, max(1, params.get("page_size", 20)))
|
|
|
|
|
|
total = query.count()
|
|
|
|
|
|
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": 0,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"total": total,
|
|
|
|
|
|
"items": [r.to_dict() for r in items],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def get_record(self, record_id: int, user_id: str) -> dict:
|
2026-07-27 13:52:09 +08:00
|
|
|
|
"""记录详情(含任务状态,用于轮询)。"""
|
2026-07-23 15:04:16 +08:00
|
|
|
|
record = PosterRecord.query.get(record_id)
|
|
|
|
|
|
if not record or record.user_id != user_id:
|
|
|
|
|
|
return {"code": 1002, "message": "记录不存在", "data": None}
|
|
|
|
|
|
return {"code": 0, "data": record.to_dict()}
|