"""用户产品小册子的上传、解析状态、确认和留存服务。""" 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")) 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: 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 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), ), ).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}