baodan/api/insurance/poster/product_material_service.py

338 lines
14 KiB
Python
Raw Normal View History

2026-07-31 09:50:46 +08:00
"""用户产品小册子的上传、解析状态、确认和留存服务。"""
import hashlib
import json
import os
import uuid
from datetime import datetime
from sqlalchemy import or_
from insurance.config import get_storage_root
from insurance.db.compat import db
from insurance.models.user_product_material import UserProductMaterial
def _user_storage_key(user_id: str) -> str:
"""使用不可逆摘要生成稳定目录名,避免路径穿越和暴露用户 ID。"""
return hashlib.sha256(user_id.encode("utf-8")).hexdigest()[:24]
def _clean_original_name(filename: str) -> str:
value = (filename or "product-manual.pdf").replace("\\", "/").split("/")[-1]
value = value.replace("\x00", "").strip()
return (value or "product-manual.pdf")[:200]
def resolve_file_key(file_key: str) -> str:
"""将相对文件键解析到 storage并阻止越界访问。"""
storage_root = os.path.abspath(get_storage_root())
manual_root = os.path.abspath(os.path.join(storage_root, "uploads", "product-manuals"))
2026-07-31 09:50:46 +08:00
target = os.path.abspath(os.path.join(storage_root, file_key.replace("/", os.sep)))
try:
is_inside_manual_root = os.path.commonpath([manual_root, target]) == manual_root
except ValueError:
is_inside_manual_root = False
if not is_inside_manual_root:
2026-07-31 09:50:46 +08:00
raise ValueError("文件路径无效")
return target
def _pdf_page_count(pdf_bytes: bytes) -> int | None:
try:
import io
import pypdf
return len(pypdf.PdfReader(io.BytesIO(pdf_bytes)).pages)
except Exception:
return None
def _string_list(value, max_items: int = 20, max_length: int = 500) -> list[str]:
if not isinstance(value, list):
return []
result = []
for item in value[:max_items]:
if isinstance(item, str) and item.strip():
result.append(item.strip()[:max_length])
return result
def validate_confirmed_rules(value: dict) -> tuple[bool, str, dict | None]:
"""校验并规范化用户确认后的产品规则。"""
if not isinstance(value, dict):
return False, "确认数据格式错误", None
product_name = str(value.get("product_name") or value.get("productName") or "").strip()
if not product_name:
return False, "请填写产品名称", None
if len(product_name) > 150:
return False, "产品名称不能超过 150 个字符", None
raw_features = value.get("features")
if not isinstance(raw_features, list) or not raw_features:
return False, "请至少保留一个产品卖点", None
features = []
for index, feature in enumerate(raw_features[:8], start=1):
if not isinstance(feature, dict):
return False, f"{index} 个产品卖点格式错误", None
title = str(feature.get("title") or "").strip()
summary = str(feature.get("summary") or "").strip()
if not title:
return False, f"请填写第 {index} 个产品卖点标题", None
if not summary:
return False, f"请填写第 {index} 个产品卖点摘要", None
2026-07-31 09:50:46 +08:00
item = {
"code": str(feature.get("code") or f"feature_{index}")[:50],
"title": title[:100],
"summary": summary[:500],
}
source_page = feature.get("source_page")
if isinstance(source_page, int) and source_page > 0:
item["source_page"] = source_page
features.append(item)
investment_rules = value.get("investment_rules")
if not isinstance(investment_rules, dict):
investment_rules = {}
normalized = {
"product_name": product_name,
"features": features,
"currency_options": _string_list(value.get("currency_options"), 10, 20),
"coverage_highlights": _string_list(value.get("coverage_highlights"), 10),
"bonus_mechanism": str(value.get("bonus_mechanism") or "")[:2000],
"flexible_options": _string_list(value.get("flexible_options"), 20),
"risk_warnings": _string_list(value.get("risk_warnings"), 20),
"investment_rules": investment_rules,
}
return True, "", normalized
class ProductMaterialService:
"""当前用户私有产品资料的业务服务。"""
def list_available(self, user_id: str) -> list[dict]:
materials = UserProductMaterial.query.filter(
UserProductMaterial.owner_user_id == user_id,
UserProductMaterial.status == 1,
UserProductMaterial.deleted_at.is_(None),
or_(
UserProductMaterial.parse_status == "parsed",
UserProductMaterial.confirmed_at.isnot(None),
),
2026-07-31 09:50:46 +08:00
).order_by(
UserProductMaterial.updated_at.desc(),
UserProductMaterial.id.desc(),
).all()
return [material.to_dict() for material in materials]
def list_materials(self, user_id: str, params: dict) -> dict:
query = UserProductMaterial.query.filter(
UserProductMaterial.owner_user_id == user_id,
UserProductMaterial.status == 1,
UserProductMaterial.deleted_at.is_(None),
)
search = str(params.get("search") or "").strip()
if search:
pattern = f"%{search}%"
query = query.filter(or_(
UserProductMaterial.product_name.ilike(pattern),
UserProductMaterial.company_name.ilike(pattern),
UserProductMaterial.original_name.ilike(pattern),
))
page = max(1, int(params.get("page") or 1))
page_size = min(100, max(1, int(params.get("page_size") or 50)))
total = query.count()
items = query.order_by(
UserProductMaterial.updated_at.desc(),
UserProductMaterial.id.desc(),
).offset((page - 1) * page_size).limit(page_size).all()
return {"code": 0, "data": {"total": total, "items": [m.to_dict() for m in items]}}
def upload(self, user_id: str, file, password: str = "",
company_id: str = "", plan_type: str = "") -> dict:
if not file or not file.filename:
return {"code": 4101, "message": "请上传产品小册子 PDF", "data": None}
if not file.filename.lower().endswith(".pdf"):
return {"code": 4101, "message": "仅支持 PDF 格式的产品小册子", "data": None}
from insurance.utils.security import prepare_pdf_upload
is_valid, message, pdf_bytes = prepare_pdf_upload(file, password)
if not is_valid or pdf_bytes is None:
code = 4102 if "密码" in message else 4101
return {"code": code, "message": message, "data": None}
digest = hashlib.sha256(pdf_bytes).hexdigest()
existing = UserProductMaterial.query.filter(
UserProductMaterial.owner_user_id == user_id,
UserProductMaterial.sha256 == digest,
UserProductMaterial.status == 1,
UserProductMaterial.deleted_at.is_(None),
).order_by(UserProductMaterial.id.desc()).first()
if existing:
data = existing.to_dict()
data["deduplicated"] = True
return {"code": 0, "message": "该小册子已存在", "data": data}
file_key = (
f"uploads/product-manuals/users/{_user_storage_key(user_id)}/"
f"{uuid.uuid4().hex}.pdf"
)
filepath = resolve_file_key(file_key)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "wb") as output:
output.write(pdf_bytes)
material = UserProductMaterial(
owner_user_id=user_id,
tenant_id="default",
company_id=company_id or None,
plan_type=plan_type or None,
original_name=_clean_original_name(file.filename),
file_key=file_key,
file_size=len(pdf_bytes),
page_count=_pdf_page_count(pdf_bytes),
sha256=digest,
parse_status="queued",
parse_message="任务已提交,等待解析...",
review_status="private",
status=1,
)
db.session.add(material)
db.session.commit()
dispatch_result = self._dispatch_parse(material)
if dispatch_result is not None:
return dispatch_result
return {"code": 0, "data": material.to_dict()}
def get(self, material_id: int, user_id: str) -> dict:
material = self._owned(material_id, user_id)
if not material:
return {"code": 4106, "message": "产品资料不存在或无权访问", "data": None}
return {"code": 0, "data": material.to_dict()}
def confirm(self, material_id: int, user_id: str, data: dict) -> dict:
material = self._owned(material_id, user_id)
if not material:
return {"code": 4106, "message": "产品资料不存在或无权访问", "data": None}
if material.parse_status != "parsed":
return {"code": 4103, "message": "小册子尚未解析完成", "data": None}
rules = data.get("confirmedRules") or data.get("rules")
is_valid, message, normalized = validate_confirmed_rules(rules)
if not is_valid:
return {"code": 1001, "message": message, "data": None}
company_name = str(data.get("companyName") or material.company_name or "").strip()
company_id = str(data.get("companyId") or material.company_id or "").strip()
if company_id:
from insurance.models.ppt_config import PptCompany
company = PptCompany.query.filter_by(id=company_id, status=1).first()
if not company:
return {"code": 1001, "message": "所选保司不存在或已停用", "data": None}
company_name = company.display_name
if not company_name:
return {"code": 1001, "message": "请填写或选择所属保司", "data": None}
plan_type = str(data.get("planType") or material.plan_type or "other").strip()
if plan_type not in ("savings", "ci", "iul", "other"):
return {"code": 1001, "message": "产品类型不支持", "data": None}
material.company_id = company_id or None
material.company_name = company_name[:100]
material.product_name = normalized["product_name"]
material.plan_type = plan_type
material.confirmed_rules = json.dumps(normalized, ensure_ascii=False)
material.confirmed_by = user_id
material.confirmed_at = datetime.now()
material.review_status = "private"
db.session.commit()
return {"code": 0, "data": material.to_dict()}
def retry(self, material_id: int, user_id: str) -> dict:
material = self._owned(material_id, user_id)
if not material:
return {"code": 4106, "message": "产品资料不存在或无权访问", "data": None}
if material.parse_status in ("queued", "parsing"):
return {"code": 0, "message": "解析任务正在进行", "data": material.to_dict()}
filepath = resolve_file_key(material.file_key)
if not os.path.exists(filepath):
return {"code": 4107, "message": "小册子原文件不存在,请重新上传", "data": None}
material.parse_status = "queued"
material.parse_message = "任务已提交,等待解析..."
material.parse_error = None
material.parse_task_id = None
material.parse_started_at = None
material.parse_finished_at = None
material.parsed_rules = None
material.confirmed_rules = None
material.confirmed_by = None
material.confirmed_at = None
db.session.commit()
dispatch_result = self._dispatch_parse(material)
if dispatch_result is not None:
return dispatch_result
return {"code": 0, "data": material.to_dict()}
def delete(self, material_id: int, user_id: str) -> dict:
material = self._owned(material_id, user_id)
if not material:
return {"code": 4106, "message": "产品资料不存在或无权访问", "data": None}
material.status = 0
material.deleted_at = datetime.now()
db.session.commit()
return {"code": 0, "data": None}
def submit_review(self, material_id: int, user_id: str) -> dict:
material = self._owned(material_id, user_id)
if not material:
return {"code": 4106, "message": "产品资料不存在或无权访问", "data": None}
if not material.confirmed_rules:
return {"code": 4105, "message": "请先确认小册子解析结果", "data": None}
material.review_status = "submitted"
db.session.commit()
return {"code": 0, "data": material.to_dict()}
def get_file(self, material_id: int, user_id: str) -> tuple[UserProductMaterial | None, str | None]:
material = self._owned(material_id, user_id)
if not material:
return None, None
filepath = resolve_file_key(material.file_key)
if not os.path.exists(filepath):
return material, None
return material, filepath
def _owned(self, material_id: int, user_id: str) -> UserProductMaterial | None:
return UserProductMaterial.query.filter(
UserProductMaterial.id == material_id,
UserProductMaterial.owner_user_id == user_id,
UserProductMaterial.status == 1,
UserProductMaterial.deleted_at.is_(None),
).first()
@staticmethod
def _dispatch_parse(material: UserProductMaterial) -> dict | None:
try:
from insurance.generation.celery_tasks import parse_user_product_material_task
result = parse_user_product_material_task.apply_async(
args=[material.id],
queue="insurance",
)
material.parse_task_id = result.id
db.session.commit()
return None
except Exception as exc:
material.parse_status = "failed"
material.parse_message = "任务提交失败"
material.parse_error = str(exc)[:1000]
material.parse_finished_at = datetime.now()
db.session.commit()
return {"code": 5001, "message": "解析任务提交失败,请稍后重试", "data": None}