"""PPT/海报管理后台服务。""" import hashlib import json import logging import os import uuid from insurance.db.compat import db from insurance.models.ppt_config import PptCompany, PptProduct, PptScenario, PptTemplate from insurance.models.ppt_history import PptHistory from insurance.models.poster_template_model import PosterTemplate from insurance.models.poster_copy_template import PosterCopyTemplate from insurance.models.poster_record import PosterRecord from insurance.models.system_setting import SystemSetting from sqlalchemy import or_ logger = logging.getLogger(__name__) def _storage_key(value: str) -> str: """将外部 ID 转为安全、稳定的存储目录名。""" return hashlib.sha256(value.encode("utf-8")).hexdigest()[:32] def _safe_page_params(params: dict) -> tuple[int, int]: """安全获取分页参数,防止负数和超大值。""" page = max(1, params.get("page", 1)) page_size = min(100, max(1, params.get("page_size", 20))) return page, page_size def _poster_compatibility_error(modes, formats) -> str | None: if not isinstance(modes, list) or not modes or not set(modes).issubset({"single", "long"}): return "supportedModes 不合法" from insurance.poster.format_registry import list_poster_formats specs = {item["id"]: item for item in list_poster_formats()} if not isinstance(formats, list) or not formats or not set(formats).issubset(specs): return "supportedFormats 不合法" incompatible = [item for item in formats if specs[item]["outputMode"] not in modes] if incompatible: return f"格式与兼容模式不一致: {', '.join(incompatible)}" return None class PptAdminService: """PPT/海报管理后台业务逻辑。""" # ---- 保司管理 ---- def list_companies(self, params: dict) -> dict: query = db.session.query(PptCompany).filter(PptCompany.deleted_at.is_(None)) if params.get("status") is not None: query = query.filter(PptCompany.status == params["status"]) if params.get("keyword"): kw = f"%{params['keyword']}%" query = query.filter(or_( PptCompany.display_name.ilike(kw), PptCompany.name_zh.ilike(kw), PptCompany.name_en.ilike(kw), )) query = query.order_by(PptCompany.sort_order.asc(), PptCompany.id.asc()) page, page_size = _safe_page_params(params) total = query.count() items = query.offset((page - 1) * page_size).limit(page_size).all() return { "code": 0, "data": { "total": total, "items": [c.to_dict() for c in items], }, } def create_company(self, data: dict) -> dict: if not data.get("id") or not data.get("displayName"): return {"code": 1001, "message": "id 和 displayName 必填", "data": None} if data.get("maskingEnabled") and not data.get("maskedDisplayName"): return {"code": 1001, "message": "开启名称脱敏前请填写脱敏展示名", "data": None} if PptCompany.query.get(data["id"]): return {"code": 1001, "message": "公司 ID 已存在", "data": None} company = PptCompany( id=data["id"], display_name=data["displayName"], aliases_json=json.dumps(data.get("aliases", []), ensure_ascii=False), name_zh=data.get("nameZh"), name_en=data.get("nameEn"), short_en=data.get("shortEn"), logo_url=data.get("logoUrl"), company_intro=data.get("companyIntro"), masked_display_name=data.get("maskedDisplayName"), masking_enabled=bool(data.get("maskingEnabled", False)), logo_enabled=bool(data.get("logoEnabled", True)), status=data.get("status", 1), sort_order=data.get("sortOrder", 0), ) db.session.add(company) db.session.commit() return {"code": 0, "data": company.to_dict()} def update_company(self, company_id: str, data: dict, user_id: str = "system") -> dict: company = PptCompany.query.get(company_id) if not company or company.deleted_at: return {"code": 1002, "message": "公司不存在", "data": None} old_value = company.to_dict() next_masked_name = ( data.get("maskedDisplayName") if "maskedDisplayName" in data else company.masked_display_name ) next_masking_enabled = ( bool(data.get("maskingEnabled")) if "maskingEnabled" in data else bool(company.masking_enabled) ) if next_masking_enabled and not next_masked_name: return {"code": 1001, "message": "开启名称脱敏前请填写脱敏展示名", "data": None} if "displayName" in data: company.display_name = data["displayName"] if "aliases" in data: company.aliases_json = json.dumps(data["aliases"], ensure_ascii=False) if "nameZh" in data: company.name_zh = data["nameZh"] if "nameEn" in data: company.name_en = data["nameEn"] if "shortEn" in data: company.short_en = data["shortEn"] if "logoUrl" in data: company.logo_url = data["logoUrl"] if "companyIntro" in data: company.company_intro = data["companyIntro"] if "maskedDisplayName" in data: company.masked_display_name = data["maskedDisplayName"] or None if "maskingEnabled" in data: company.masking_enabled = bool(data["maskingEnabled"]) if "logoEnabled" in data: company.logo_enabled = bool(data["logoEnabled"]) if "status" in data: company.status = data["status"] if "sortOrder" in data: company.sort_order = data["sortOrder"] db.session.commit() from insurance.utils.audit import log_config_change log_config_change(user_id, "ppt_company", company_id, old_value, company.to_dict()) return {"code": 0, "data": company.to_dict()} def update_company_status(self, company_id: str, data: dict) -> dict: company = PptCompany.query.get(company_id) if not company or company.deleted_at: return {"code": 1002, "message": "公司不存在", "data": None} company.status = data.get("status", 1) db.session.commit() return {"code": 0, "data": company.to_dict()} def delete_company(self, company_id: str, user_id: str = "system") -> dict: company = PptCompany.query.filter_by(id=company_id, deleted_at=None).first() if not company: return {"code": 1002, "message": "公司不存在", "data": None} active_products = PptProduct.query.filter_by( company_id=company_id, status=1, deleted_at=None ).count() if active_products: return { "code": 1003, "message": f"该保司仍有 {active_products} 个启用产品,请先停用或删除产品", "data": {"activeProductCount": active_products}, } from datetime import datetime company.status = 0 company.deleted_at = datetime.now() db.session.commit() from insurance.utils.audit import log_operation log_operation(user_id, "delete", "ppt_company", company_id, {"softDelete": True}) return {"code": 0, "data": None} # ---- 保司Logo管理 ---- # 允许的图片类型和大小限制 _ALLOWED_LOGO_TYPES = {"image/png", "image/jpeg", "image/webp"} _MAX_LOGO_SIZE = 5 * 1024 * 1024 # 5MB _MAX_LOGOS_PER_COMPANY = 10 def list_company_logos(self, company_id: str) -> dict: from insurance.models.ppt_config import CompanyLogo logos = CompanyLogo.query.filter_by(company_id=company_id)\ .order_by(CompanyLogo.sort_order.asc(), CompanyLogo.id.asc()).all() return {"code": 0, "data": [logo.to_dict() for logo in logos]} def upload_company_logos(self, company_id: str, files: list) -> dict: """上传多张保司Logo图片。""" from insurance.models.ppt_config import CompanyLogo company = PptCompany.query.get(company_id) if not company: return {"code": 1002, "message": "公司不存在", "data": None} # 检查数量限制 existing_count = CompanyLogo.query.filter_by(company_id=company_id).count() if existing_count + len(files) > self._MAX_LOGOS_PER_COMPANY: return { "code": 1003, "message": f"最多上传 {self._MAX_LOGOS_PER_COMPANY} 张Logo,当前已有 {existing_count} 张", "data": None, } from insurance.config import get_storage_root upload_dir = os.path.join( get_storage_root(), "uploads", "company-logos", _storage_key(company_id) ) os.makedirs(upload_dir, exist_ok=True) uploaded = [] errors = [] for file in files: if not file.filename: continue # 校验类型 if file.content_type not in self._ALLOWED_LOGO_TYPES: errors.append(f"{file.filename}: 仅支持 PNG/JPEG/WebP 格式") continue # 校验大小 file.seek(0, os.SEEK_END) size = file.tell() file.seek(0) if size == 0: errors.append(f"{file.filename}: 文件为空") continue if size > self._MAX_LOGO_SIZE: errors.append(f"{file.filename}: 文件大小超过 5MB 限制") continue # 用 Pillow 验证并重编码(去除 EXIF) try: from PIL import Image img = Image.open(file.stream) detected_format = (img.format or "").upper() if detected_format not in ("PNG", "JPEG", "WEBP"): raise ValueError("图片实际格式不受支持") img.verify() file.seek(0) img = Image.open(file.stream) img.load() # JPEG 不支持透明通道;PNG/WebP 保留透明背景。 if detected_format == "JPEG" and img.mode != "RGB": img = img.convert("RGB") elif detected_format in ("PNG", "WEBP") and img.mode not in ("RGB", "RGBA"): img = img.convert("RGBA") save_format = detected_format mime = f"image/{save_format.lower()}" except Exception as e: errors.append(f"{file.filename}: 图片文件损坏 ({e})") continue # 保存文件 logo_id = uuid.uuid4().hex extension = "jpg" if save_format == "JPEG" else save_format.lower() filename = f"{logo_id}.{extension}" filepath = os.path.join(upload_dir, filename) img.save(filepath, format=save_format, quality=90) # 创建记录 logo = CompanyLogo( id=logo_id, company_id=company_id, file_path=filepath, file_url=f"/insurance/admin/ppt/assets/company-logos/{logo_id}", original_name=file.filename, mime_type=mime, file_size=os.path.getsize(filepath), is_primary=(existing_count == 0 and len(uploaded) == 0), # 第一张自动设为主图 sort_order=existing_count + len(uploaded), ) db.session.add(logo) uploaded.append(logo) if uploaded: db.session.commit() # 同步主图到 company.logo_url primary = next((l for l in uploaded if l.is_primary), None) if primary: company.logo_url = primary.file_url db.session.commit() result = {"uploaded": [l.to_dict() for l in uploaded]} if errors: result["errors"] = errors return {"code": 0, "data": result} def set_primary_logo(self, company_id: str, logo_id: str) -> dict: """设置指定Logo为主图。""" from insurance.models.ppt_config import CompanyLogo logo = CompanyLogo.query.get(logo_id) if not logo or logo.company_id != company_id: return {"code": 1002, "message": "Logo不存在", "data": None} # 取消当前主图 CompanyLogo.query.filter_by(company_id=company_id, is_primary=True)\ .update({"is_primary": False}) logo.is_primary = True # 同步到 company.logo_url company = PptCompany.query.get(company_id) if company: company.logo_url = logo.file_url db.session.commit() return {"code": 0, "data": logo.to_dict()} def reorder_logos(self, company_id: str, order_data: list) -> dict: """重排Logo顺序。order_data = [{"id": "xxx", "sortOrder": 0}, ...]""" from insurance.models.ppt_config import CompanyLogo for item in order_data: logo = CompanyLogo.query.get(item.get("id")) if logo and logo.company_id == company_id: logo.sort_order = item.get("sortOrder", 0) db.session.commit() return {"code": 0, "data": None} def delete_company_logo(self, company_id: str, logo_id: str) -> dict: """删除指定Logo。""" from insurance.models.ppt_config import CompanyLogo logo = CompanyLogo.query.get(logo_id) if not logo or logo.company_id != company_id: return {"code": 1002, "message": "Logo不存在", "data": None} was_primary = logo.is_primary # 删除文件 if logo.file_path and os.path.exists(logo.file_path): try: os.remove(logo.file_path) except Exception as e: logger.warning(f"删除Logo文件失败: {e}") db.session.delete(logo) db.session.commit() # 如果删除的是主图,自动将下一张设为主图 if was_primary: next_primary = CompanyLogo.query.filter_by(company_id=company_id)\ .order_by(CompanyLogo.sort_order.asc()).first() if next_primary: next_primary.is_primary = True company = PptCompany.query.get(company_id) if company: company.logo_url = next_primary.file_url else: company = PptCompany.query.get(company_id) if company: company.logo_url = None db.session.commit() return {"code": 0, "data": None} # ---- 产品管理 ---- def list_products(self, params: dict) -> dict: query = db.session.query(PptProduct).filter(PptProduct.deleted_at.is_(None)) if params.get("company_id"): query = query.filter(PptProduct.company_id == params["company_id"]) if params.get("plan_type"): query = query.filter(PptProduct.plan_type == params["plan_type"]) if params.get("status") is not None: query = query.filter(PptProduct.status == params["status"]) query = query.order_by(PptProduct.sort_order.asc(), PptProduct.id.asc()) page, page_size = _safe_page_params(params) total = query.count() items = query.offset((page - 1) * page_size).limit(page_size).all() return { "code": 0, "data": { "total": total, "items": [p.to_dict() for p in items], }, } def create_product(self, data: dict) -> dict: if not data.get("id") or not data.get("displayName"): return {"code": 1001, "message": "id 和 displayName 必填", "data": None} if data.get("maskingEnabled") and not data.get("maskedDisplayName"): return {"code": 1001, "message": "开启名称脱敏前请填写脱敏展示名", "data": None} if PptProduct.query.get(data["id"]): return {"code": 1001, "message": "产品 ID 已存在", "data": None} product = PptProduct( id=data["id"], company_id=data.get("companyId", ""), plan_type=data.get("planType", "savings"), display_name=data["displayName"], aliases_json=json.dumps(data.get("aliases", []), ensure_ascii=False), required_modules_json=json.dumps(data.get("requiredModules", []), ensure_ascii=False), masked_display_name=data.get("maskedDisplayName"), masking_enabled=bool(data.get("maskingEnabled", False)), product_code=data.get("productCode"), product_type=data.get("productType"), coverage_period=data.get("coveragePeriod"), payment_period=data.get("paymentPeriod"), insured_age_range=data.get("insuredAgeRange"), waiting_period=data.get("waitingPeriod"), highlights=json.dumps(data.get("highlights", []), ensure_ascii=False) if data.get("highlights") else None, extra_fields=json.dumps(data.get("extraFields", {}), ensure_ascii=False) if data.get("extraFields") else None, status=data.get("status", 1), sort_order=data.get("sortOrder", 0), ) db.session.add(product) db.session.commit() return {"code": 0, "data": product.to_dict()} def update_product(self, product_id: str, data: dict, user_id: str = "system") -> dict: product = PptProduct.query.get(product_id) if not product or product.deleted_at: return {"code": 1002, "message": "产品不存在", "data": None} old_value = product.to_dict() next_masked_name = ( data.get("maskedDisplayName") if "maskedDisplayName" in data else product.masked_display_name ) next_masking_enabled = ( bool(data.get("maskingEnabled")) if "maskingEnabled" in data else bool(product.masking_enabled) ) if next_masking_enabled and not next_masked_name: return {"code": 1001, "message": "开启名称脱敏前请填写脱敏展示名", "data": None} simple_fields = { "displayName": "display_name", "companyId": "company_id", "planType": "plan_type", "productCode": "product_code", "productType": "product_type", "coveragePeriod": "coverage_period", "paymentPeriod": "payment_period", "insuredAgeRange": "insured_age_range", "waitingPeriod": "waiting_period", "status": "status", "sortOrder": "sort_order", "maskedDisplayName": "masked_display_name", "maskingEnabled": "masking_enabled", } for key, attr in simple_fields.items(): if key in data: setattr(product, attr, data[key]) json_fields = { "aliases": "aliases_json", "requiredModules": "required_modules_json", "highlights": "highlights", "extraFields": "extra_fields", } for key, attr in json_fields.items(): if key in data: setattr(product, attr, json.dumps(data[key], ensure_ascii=False) if data[key] else None) db.session.commit() from insurance.utils.audit import log_config_change log_config_change(user_id, "ppt_product", product_id, old_value, product.to_dict()) return {"code": 0, "data": product.to_dict()} def update_product_status(self, product_id: str, data: dict) -> dict: product = PptProduct.query.get(product_id) if not product or product.deleted_at: return {"code": 1002, "message": "产品不存在", "data": None} product.status = data.get("status", 1) db.session.commit() return {"code": 0, "data": product.to_dict()} def delete_product(self, product_id: str, user_id: str = "system") -> dict: product = PptProduct.query.filter_by(id=product_id, deleted_at=None).first() if not product: return {"code": 1002, "message": "产品不存在", "data": None} from datetime import datetime product.status = 0 product.deleted_at = datetime.now() db.session.commit() from insurance.utils.audit import log_operation log_operation(user_id, "delete", "ppt_product", product_id, {"softDelete": True}) return {"code": 0, "data": None} # ---- 产品小册子 ---- def upload_manual(self, product_id: str, file, password: str = "") -> dict: product = PptProduct.query.get(product_id) if not product: return {"code": 1002, "message": "产品不存在", "data": None} if not file.filename or not file.filename.lower().endswith(".pdf"): return {"code": 1003, "message": "仅支持 PDF 文件", "data": None} # 校验 PDF;加密文件使用用户本次提供的密码生成解密副本。 from insurance.utils.security import prepare_pdf_upload is_valid, err_msg, pdf_bytes = prepare_pdf_upload(file, password) if not is_valid: code = 4003 if "密码" in err_msg else 1003 return {"code": code, "message": err_msg, "data": None} # 使用持久化存储目录 from insurance.config import get_storage_root upload_dir = os.path.join( get_storage_root(), "uploads", "product-manuals", _storage_key(product_id) ) os.makedirs(upload_dir, exist_ok=True) filename = f"{uuid.uuid4().hex}.pdf" filepath = os.path.join(upload_dir, filename) with open(filepath, "wb") as output: output.write(pdf_bytes) # 更新产品记录 product.manual_file_url = filepath product.manual_parse_status = "pending" # 清空旧解析结果和错误信息 product.manual_parsed_rules = None product.manual_parse_error = None product.manual_parse_message = "" product.manual_parse_task_id = None product.manual_parse_started_at = None product.manual_parse_finished_at = None product.manual_reviewed_by = None product.manual_reviewed_at = None db.session.commit() return {"code": 0, "data": product.to_dict()} def parse_manual(self, product_id: str) -> dict: product = PptProduct.query.get(product_id) if not product: return {"code": 1002, "message": "产品不存在", "data": None} if not product.manual_file_url: return {"code": 1003, "message": "请先上传小册子", "data": None} if not os.path.exists(product.manual_file_url): return {"code": 1004, "message": "小册子文件不存在,请重新上传", "data": None} if product.manual_parse_status in ("queued", "parsing"): return {"code": 0, "message": "解析任务正在进行", "data": product.to_dict()} # 先提交状态,避免 Worker 抢先执行时读到 pending 而跳过任务。 product.manual_parse_status = "queued" product.manual_parse_message = "任务已提交,等待解析..." product.manual_parse_task_id = None product.manual_parse_error = None product.manual_parse_started_at = None product.manual_parse_finished_at = None db.session.commit() # 提交 Celery 异步任务到专用队列。 from insurance.generation.celery_tasks import parse_product_manual_task try: result = parse_product_manual_task.apply_async(args=[product_id], queue="insurance") product.manual_parse_task_id = result.id db.session.commit() except Exception as exc: logger.error(f"提交小册子解析任务失败: {exc}", exc_info=True) product.manual_parse_status = "failed" product.manual_parse_message = "任务提交失败" product.manual_parse_error = str(exc)[:1000] db.session.commit() return {"code": 1005, "message": "解析任务提交失败,请稍后重试", "data": product.to_dict()} return {"code": 0, "data": product.to_dict()} def get_manual(self, product_id: str) -> dict: """获取产品小册子解析详情。""" product = PptProduct.query.get(product_id) if not product: return {"code": 1002, "message": "产品不存在", "data": None} return {"code": 0, "data": product.to_dict()} def review_manual(self, product_id: str, data: dict) -> dict: product = PptProduct.query.get(product_id) if not product: return {"code": 1002, "message": "产品不存在", "data": None} if product.manual_parse_status not in ("parsed", "reviewed"): return {"code": 1003, "message": "产品尚未解析,无法核对", "data": None} if "rules" in data: product.manual_parsed_rules = json.dumps(data["rules"], ensure_ascii=False) if data.get("confirm", False): product.manual_parse_status = "reviewed" product.manual_reviewed_by = data.get("reviewedBy", "") from datetime import datetime product.manual_reviewed_at = datetime.now() db.session.commit() return {"code": 0, "data": product.to_dict()} # ---- PPT 模板管理 ---- def list_scenarios(self) -> dict: items = PptScenario.query.filter( PptScenario.deleted_at.is_(None) ).order_by(PptScenario.sort_order.asc(), PptScenario.code.asc()).all() return {"code": 0, "data": {"items": [item.to_dict() for item in items]}} def create_scenario(self, data: dict) -> dict: code = str(data.get("code") or "").strip() name = str(data.get("name") or "").strip() mode = str(data.get("generationMode") or "single").strip() if not code or not name: return {"code": 1001, "message": "场景编码和名称必填", "data": None} if mode not in ("single", "compare", "portfolio"): return {"code": 1001, "message": "生成模式无效", "data": None} if PptScenario.query.get(code): return {"code": 1001, "message": "场景编码已存在", "data": None} scenario = PptScenario( code=code, name=name, base_scenario=data.get("baseScenario") or None, generation_mode=mode, description=data.get("description") or "", status=data.get("status", 1), sort_order=data.get("sortOrder", 0), is_builtin=False, ) db.session.add(scenario) db.session.commit() return {"code": 0, "data": scenario.to_dict()} def update_scenario(self, code: str, data: dict) -> dict: scenario = PptScenario.query.filter_by(code=code, deleted_at=None).first() if not scenario: return {"code": 1002, "message": "场景不存在", "data": None} for key, attr in [ ("name", "name"), ("baseScenario", "base_scenario"), ("description", "description"), ("status", "status"), ("sortOrder", "sort_order"), ]: if key in data: setattr(scenario, attr, data[key]) if "generationMode" in data: if data["generationMode"] not in ("single", "compare", "portfolio"): return {"code": 1001, "message": "生成模式无效", "data": None} scenario.generation_mode = data["generationMode"] db.session.commit() return {"code": 0, "data": scenario.to_dict()} def delete_scenario(self, code: str, user_id: str = "system") -> dict: scenario = PptScenario.query.filter_by(code=code, deleted_at=None).first() if not scenario: return {"code": 1002, "message": "场景不存在", "data": None} if scenario.is_builtin: return {"code": 1003, "message": "内置场景不可删除,可改为停用", "data": None} if PptTemplate.query.filter_by(scenario_tag=code, deleted_at=None).first(): return {"code": 1003, "message": "仍有 PPT 模板引用该场景,请先调整模板", "data": None} from datetime import datetime scenario.status = 0 scenario.deleted_at = datetime.now() db.session.commit() from insurance.utils.audit import log_operation log_operation(user_id, "delete", "ppt_scenario", code, {"softDelete": True}) return {"code": 0, "data": None} def list_templates(self, params: dict) -> dict: query = db.session.query(PptTemplate).filter(PptTemplate.deleted_at.is_(None)) if params.get("plan_type"): query = query.filter(PptTemplate.plan_type == params["plan_type"]) if params.get("status") is not None: query = query.filter(PptTemplate.status == params["status"]) page, page_size = _safe_page_params(params) total = query.count() items = query.offset((page - 1) * page_size).limit(page_size).all() return { "code": 0, "data": { "total": total, "items": [t.to_dict() for t in items], }, } def create_template(self, data: dict) -> dict: if not data.get("id"): return {"code": 1001, "message": "id 必填", "data": None} if PptTemplate.query.get(data["id"]): return {"code": 1001, "message": "模板 ID 已存在", "data": None} template = PptTemplate( id=data["id"], plan_type=data.get("planType", "savings"), style_preset=data.get("stylePreset", "broker"), name=data.get("name"), scenario_tag=data.get("scenarioTag"), preview_image=data.get("previewImage"), applicable_company_ids=json.dumps(data.get("applicableCompanyIds", []), ensure_ascii=False) if data.get("applicableCompanyIds") else None, applicable_product_ids=json.dumps(data.get("applicableProductIds", []), ensure_ascii=False) if data.get("applicableProductIds") else None, slides_config_json=json.dumps(data.get("slidesConfig", []), ensure_ascii=False) if data.get("slidesConfig") else None, status=data.get("status", 1), ) db.session.add(template) db.session.commit() return {"code": 0, "data": template.to_dict()} def upload_template(self, file, data: dict, template_id: str | None = None) -> dict: """上传 PPTX,自动解析页面结构并创建或替换模板资产。""" from insurance.ppt.template_asset_service import ( delete_stored_template, parse_template_pptx, save_uploaded_template, template_asset_sha256, ) resolved_id = template_id or str(data.get("id") or "").strip() if not resolved_id: return {"code": 1001, "message": "模板 ID 必填", "data": None} template = PptTemplate.query.get(resolved_id) if template_id and not template: return {"code": 1002, "message": "模板不存在", "data": None} if not template_id and template: return {"code": 1001, "message": "模板 ID 已存在", "data": None} new_asset_id = None try: new_asset_id, file_path = save_uploaded_template(file, resolved_id) parsed = parse_template_pptx(file_path) asset_sha256 = template_asset_sha256(file_path) except ValueError as exc: delete_stored_template(new_asset_id) return {"code": 1001, "message": str(exc), "data": None} old_asset_id = template.source_template_asset_id if template else None old_asset_version = int(template.asset_version or 0) if template else 0 if template is None: template = PptTemplate(id=resolved_id) db.session.add(template) template.plan_type = data.get("planType") or template.plan_type or "savings" template.style_preset = data.get("stylePreset") or template.style_preset or "broker" template.name = data.get("name") or template.name or file.filename template.scenario_tag = data.get("scenarioTag") or template.scenario_tag template.source_template_asset_id = new_asset_id template.asset_sha256 = asset_sha256 template.asset_version = old_asset_version + 1 template.clone_ready = True template.clone_renderer = "clone-edit-v2" template.required_page_types_json = json.dumps( parsed["requiredPageTypes"], ensure_ascii=False ) template.slides_config_json = json.dumps( parsed["slidesConfig"], ensure_ascii=False ) template.status = int(data.get("status", template.status if template.status is not None else 1)) try: db.session.commit() except Exception: db.session.rollback() delete_stored_template(new_asset_id) raise # 旧资产可能仍被排队任务的不可变快照引用,不能在切换当前版本时删除。 # 物理清理由保留期任务统一处理。 result = template.to_dict() result["slideCount"] = parsed["slideCount"] result["dimensions"] = { "width": parsed["width"], "height": parsed["height"], } result["previousAssetId"] = old_asset_id return {"code": 0, "data": result} def update_template(self, template_id: str, data: dict) -> dict: template = PptTemplate.query.get(template_id) if not template or template.deleted_at: return {"code": 1002, "message": "模板不存在", "data": None} for key, attr in [ ("name", "name"), ("planType", "plan_type"), ("stylePreset", "style_preset"), ("scenarioTag", "scenario_tag"), ("previewImage", "preview_image"), ]: if key in data: setattr(template, attr, data[key]) if "applicableCompanyIds" in data: template.applicable_company_ids = json.dumps(data["applicableCompanyIds"], ensure_ascii=False) if data["applicableCompanyIds"] else None if "applicableProductIds" in data: template.applicable_product_ids = json.dumps(data["applicableProductIds"], ensure_ascii=False) if data["applicableProductIds"] else None if "slidesConfig" in data: template.slides_config_json = json.dumps(data["slidesConfig"], ensure_ascii=False) if data["slidesConfig"] else None if "status" in data: template.status = data["status"] db.session.commit() return {"code": 0, "data": template.to_dict()} def update_template_status(self, template_id: str, data: dict) -> dict: template = PptTemplate.query.get(template_id) if not template or template.deleted_at: return {"code": 1002, "message": "模板不存在", "data": None} template.status = data.get("status", 1) db.session.commit() return {"code": 0, "data": template.to_dict()} def delete_template(self, template_id: str, user_id: str = "system") -> dict: template = PptTemplate.query.filter_by(id=template_id, deleted_at=None).first() if not template: return {"code": 1002, "message": "模板不存在", "data": None} if str(template.source_template_asset_id or "").startswith("builtin://"): return {"code": 1003, "message": "内置模板不可删除,可将其停用", "data": None} from datetime import datetime template.status = 0 template.deleted_at = datetime.now() db.session.commit() from insurance.utils.audit import log_operation log_operation(user_id, "delete", "ppt_template", template_id, {"softDelete": True}) return {"code": 0, "data": None} # ---- 海报模板管理 ---- def list_poster_templates(self, params: dict) -> dict: query = db.session.query(PosterTemplate) if params.get("status") is not None: query = query.filter(PosterTemplate.status == params["status"]) page, page_size = _safe_page_params(params) total = query.count() items = query.offset((page - 1) * page_size).limit(page_size).all() return { "code": 0, "data": { "total": total, "items": [t.to_dict() for t in items], }, } def create_poster_template(self, data: dict) -> dict: if not data.get("name") or not data.get("styleDescription"): return {"code": 1001, "message": "name 和 styleDescription 必填", "data": None} modes = data.get("supportedModes") or ["single", "long"] formats = data.get("supportedFormats") or [ "single_2_3", "single_9_16", "long_1242_auto", ] compatibility_error = _poster_compatibility_error(modes, formats) if compatibility_error: return {"code": 1001, "message": compatibility_error, "data": None} template = PosterTemplate( name=data["name"], scenario_tag=data.get("scenarioTag"), style_description=data["styleDescription"], color_scheme=json.dumps(data["colorScheme"], ensure_ascii=False) if data.get("colorScheme") else None, reference_image=data.get("referenceImage"), preview_image=data.get("previewImage"), layout_key=data.get("layoutKey") or "legacy", supported_modes=json.dumps(modes, ensure_ascii=False), supported_formats=json.dumps(formats, ensure_ascii=False), status=data.get("status", 1), ) db.session.add(template) db.session.commit() return {"code": 0, "data": template.to_dict()} def update_poster_template(self, template_id: int, data: dict) -> dict: template = PosterTemplate.query.get(template_id) if not template: return {"code": 1002, "message": "模板不存在", "data": None} for key, attr in [("name", "name"), ("scenarioTag", "scenario_tag"), ("styleDescription", "style_description"), ("referenceImage", "reference_image"), ("previewImage", "preview_image")]: if key in data: setattr(template, attr, data[key]) if "colorScheme" in data: template.color_scheme = json.dumps(data["colorScheme"], ensure_ascii=False) if data["colorScheme"] else None if "layoutKey" in data: template.layout_key = data["layoutKey"] or "legacy" if "supportedModes" in data or "supportedFormats" in data: current = template.to_dict() modes = data.get("supportedModes", current["supportedModes"]) formats = data.get("supportedFormats", current["supportedFormats"]) compatibility_error = _poster_compatibility_error(modes, formats) if compatibility_error: return {"code": 1001, "message": compatibility_error, "data": None} template.supported_modes = json.dumps(modes, ensure_ascii=False) template.supported_formats = json.dumps(formats, ensure_ascii=False) if "status" in data: template.status = data["status"] db.session.commit() return {"code": 0, "data": template.to_dict()} def update_poster_template_status(self, template_id: int, data: dict) -> dict: template = PosterTemplate.query.get(template_id) if not template: return {"code": 1002, "message": "模板不存在", "data": None} template.status = data.get("status", 1) db.session.commit() return {"code": 0, "data": template.to_dict()} # ---- 文案模板管理 ---- def list_copy_templates(self, params: dict) -> dict: query = db.session.query(PosterCopyTemplate).filter( PosterCopyTemplate.deleted_at.is_(None) ) if params.get("status") is not None: query = query.filter(PosterCopyTemplate.status == params["status"]) page, page_size = _safe_page_params(params) total = query.count() items = query.offset((page - 1) * page_size).limit(page_size).all() return { "code": 0, "data": { "total": total, "items": [t.to_dict() for t in items], }, } def create_copy_template(self, data: dict) -> dict: if not data.get("name") or not data.get("content"): return {"code": 1001, "message": "name 和 content 必填", "data": None} template = PosterCopyTemplate( name=data["name"], scenario_tag=data.get("scenarioTag"), content=data["content"], variables=json.dumps(data.get("variables", []), ensure_ascii=False) if data.get("variables") else None, status=data.get("status", 1), ) db.session.add(template) db.session.commit() return {"code": 0, "data": template.to_dict()} def update_copy_template(self, template_id: int, data: dict) -> dict: template = PosterCopyTemplate.query.get(template_id) if not template or template.deleted_at: return {"code": 1002, "message": "模板不存在", "data": None} for key, attr in [("name", "name"), ("scenarioTag", "scenario_tag"), ("content", "content")]: if key in data: setattr(template, attr, data[key]) if "variables" in data: template.variables = json.dumps(data["variables"], ensure_ascii=False) if data["variables"] else None if "status" in data: template.status = data["status"] db.session.commit() return {"code": 0, "data": template.to_dict()} def delete_copy_template(self, template_id: int, user_id: str = "system") -> dict: template = PosterCopyTemplate.query.filter_by( id=template_id, deleted_at=None ).first() if not template: return {"code": 1002, "message": "模板不存在", "data": None} from datetime import datetime template.status = 0 template.deleted_at = datetime.now() db.session.commit() from insurance.utils.audit import log_operation log_operation( user_id, "delete", "poster_copy_template", str(template_id), {"softDelete": True}, ) return {"code": 0, "data": None} # ---- 历史记录管理 ---- def list_history(self, params: dict) -> dict: query = db.session.query(PptHistory) if params.get("user_id"): query = query.filter(PptHistory.user_id == params["user_id"]) if params.get("company_id"): query = query.filter(PptHistory.company_id == params["company_id"]) if params.get("action_type"): query = query.filter(PptHistory.action_type == params["action_type"]) query = query.order_by(PptHistory.created_at.desc()) page, page_size = _safe_page_params(params) total = query.count() items = query.offset((page - 1) * page_size).limit(page_size).all() return { "code": 0, "data": { "total": total, "items": [h.to_dict() for h in items], }, } def delete_history(self, history_id: int) -> dict: record = PptHistory.query.get(history_id) if not record: return {"code": 1002, "message": "记录不存在", "data": None} db.session.delete(record) db.session.commit() return {"code": 0, "data": None} def export_history_csv(self, params: dict): """导出历史记录为 CSV。""" import csv import io query = db.session.query(PptHistory) if params.get("user_id"): query = query.filter(PptHistory.user_id == params["user_id"]) if params.get("company_id"): query = query.filter(PptHistory.company_id == params["company_id"]) if params.get("action_type"): query = query.filter(PptHistory.action_type == params["action_type"]) query = query.order_by(PptHistory.created_at.desc()) items = query.limit(10000).all() # 限制最大导出行数 output = io.StringIO() output.write("") # UTF-8 BOM,确保 Excel 正确显示中文 writer = csv.writer(output) writer.writerow(["ID", "用户ID", "会话ID", "操作类型", "保司ID", "产品ID", "文件地址", "时间"]) for h in items: writer.writerow([ h.id, h.user_id, h.session_id or "", h.action_type, h.company_id or "", h.product_id or "", h.file_url or "", str(h.created_at) if h.created_at else "", ]) return output.getvalue() # ---- 系统配置 ---- # 内置常用模型列表(Dify 不可用时的降级方案) _BUILTIN_MODELS = [ {"provider": "deepseek", "model": "deepseek-chat", "label": "DeepSeek Chat"}, {"provider": "deepseek", "model": "deepseek-reasoner", "label": "DeepSeek Reasoner"}, {"provider": "minimax", "model": "MiniMax-2.7-Flash", "label": "MiniMax 2.7 Flash"}, {"provider": "gemini", "model": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"}, {"provider": "gemini", "model": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"}, {"provider": "openai", "model": "gpt-4o", "label": "GPT-4o"}, {"provider": "openai", "model": "gpt-4o-mini", "label": "GPT-4o Mini"}, {"provider": "openai", "model": "gpt-image-1", "label": "GPT Image 1"}, ] def get_available_models(self) -> dict: """获取可用 LLM 模型列表,优先从 Dify 获取,失败时返回内置列表。""" # 尝试从 Dify 获取 dify_models = self._fetch_dify_models() if dify_models is not None: return {"code": 0, "data": {"models": dify_models, "source": "dify"}} return {"code": 0, "data": {"models": self._BUILTIN_MODELS, "source": "builtin"}} # 模型名前缀 → 品牌名映射(Dify 用 openai_api_compatible 统一接口,需要从模型名推断品牌) _MODEL_BRAND_MAP = { "deepseek": "DeepSeek", "gpt": "OpenAI", "o1": "OpenAI", "o3": "OpenAI", "o4": "OpenAI", "claude": "Anthropic", "gemini": "Google", "qwen": "通义千问", "glm": "智谱", "cogview": "智谱", "MiniMax": "MiniMax", "moonshot": "月之暗面", "doubao": "豆包", } def _infer_brand(self, model_name: str) -> str: """从模型名推断品牌名。""" for prefix, brand in self._MODEL_BRAND_MAP.items(): if model_name.lower().startswith(prefix.lower()): return brand return "其他" def _fetch_dify_models(self) -> list | None: """从 Dify 数据库直接查询已配置的 LLM 模型列表。""" try: rows = db.session.execute(db.text( "SELECT DISTINCT pm.model_name, pm.provider_name " "FROM provider_models pm " "WHERE pm.model_type = 'llm' AND pm.is_valid = true " "ORDER BY pm.provider_name, pm.model_name" )).fetchall() if not rows: return None models = [] for row in rows: model_name = row[0] provider_name = row[1] brand = self._infer_brand(model_name) models.append({ "provider": brand, "model": model_name, "label": f"{brand} / {model_name}", }) return models except Exception as e: logger.warning(f"从 Dify 数据库获取模型列表失败: {e}") return None # 需要掩码处理的敏感 key(包含 api_key 或 secret 等) _SENSITIVE_KEYS = {"api_key", "secret", "password", "token"} @staticmethod def _mask_value(key: str, value: str) -> str: """对敏感配置项进行掩码,不返回完整密钥。""" if not value: return value key_lower = key.lower() if any(s in key_lower for s in PptAdminService._SENSITIVE_KEYS): if len(value) <= 4: return "****" return f"{value[:2]}****{value[-2:]}" return value def get_settings(self) -> dict: settings = SystemSetting.query.all() data = {} for s in settings: data[s.key] = s.value return { "code": 0, "data": data, } # 允许通过 API 写入的设置键白名单 _ALLOWED_SETTING_KEYS = { "ppt_llm_provider", "ppt_llm_model", "ppt_llm_api_key", "ppt_llm_base_url", "ppt_llm_timeout_ms", "poster_llm_provider", "poster_llm_model", "poster_llm_api_key", "poster_llm_base_url", "poster_image_provider", "poster_image_model", "poster_image_api_key", "poster_image_base_url", "poster_history_retention_days", "ppt_history_retention_days", "dify_workspace_api_key", } @staticmethod def _is_masked_value(value: str) -> bool: """判断值是否为掩码格式(如 sk-****abcd)。""" return isinstance(value, str) and "****" in value @staticmethod def _validate_settings(data: dict) -> str | None: """校验设置值的安全性,返回错误信息或 None。""" from insurance.utils.security import is_safe_base_url allowed = PptAdminService._ALLOWED_SETTING_KEYS for key, value in data.items(): if key not in allowed: return f"不允许的配置键: {key}" if not isinstance(value, str): continue # 校验 Base URL 字段 if "base_url" in key.lower() and value.strip(): is_safe, err_msg = is_safe_base_url(value.strip()) if not is_safe: return f"{key}: {err_msg}" return None def update_settings(self, data: dict, updated_by: str = "") -> dict: # 安全校验(SEC-P1-02) validation_error = self._validate_settings(data) if validation_error: return {"code": 1001, "message": validation_error, "data": None} for key, value in data.items(): value_str = str(value) if value is not None else "" setting = SystemSetting.query.filter_by(key=key).first() if setting: # 如果是掩码值,跳过更新(保留真实密钥) if self._is_masked_value(value_str): continue setting.value = value_str setting.updated_by = updated_by else: # 新建设置项,不允许掩码值 if self._is_masked_value(value_str): continue db.session.add(SystemSetting(key=key, value=value_str, updated_by=updated_by)) db.session.commit() return {"code": 0, "data": None} def sync_models(self, provider: str, api_key: str, base_url: str = "") -> dict: """从供应商 API 拉取可用模型列表。""" if not api_key: return {"code": 1001, "message": "请填写 API Key", "data": None} try: models = self._fetch_provider_models(provider, api_key, base_url) except Exception as e: logger.warning(f"同步模型失败: {e}") return {"code": 5001, "message": f"同步失败: {e}", "data": None} if not models: return {"code": 0, "data": {"models": [], "message": "未获取到模型"}} return {"code": 0, "data": {"models": models}} def _fetch_provider_models(self, provider: str, api_key: str, base_url: str) -> list: """根据供应商类型拉取模型列表。""" import httpx if provider == "deepseek": url = "https://api.deepseek.com/v1/models" headers = {"Authorization": f"Bearer {api_key}"} resp = httpx.get(url, headers=headers, timeout=10) resp.raise_for_status() return [m["id"] for m in resp.json().get("data", [])] elif provider == "gemini": url = f"https://generativelanguage.googleapis.com/v1/models?key={api_key}" resp = httpx.get(url, timeout=10) resp.raise_for_status() return [m["name"].split("/")[-1] for m in resp.json().get("models", [])] elif provider == "minimax": url = "https://api.minimax.chat/v1/models" headers = {"Authorization": f"Bearer {api_key}"} resp = httpx.get(url, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() models = data.get("models") or data.get("data", []) return [m.get("id") or m.get("model", "") for m in models if m] elif provider == "dify": return [m["model"] for m in (self._fetch_dify_models() or [])] else: # 自定义供应商:OpenAI 兼容 /models 端点 if not base_url: return [] url = f"{base_url.rstrip('/')}/models" headers = {"Authorization": f"Bearer {api_key}"} resp = httpx.get(url, headers=headers, timeout=10) resp.raise_for_status() return [m["id"] for m in resp.json().get("data", [])]