baodan/api/insurance/poster/product_material_service.py

329 lines
14 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 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())
target = os.path.abspath(os.path.join(storage_root, file_key.replace("/", os.sep)))
if os.path.commonpath([storage_root, target]) != storage_root:
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
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),
UserProductMaterial.confirmed_rules.isnot(None),
UserProductMaterial.confirmed_at.isnot(None),
).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}