baodan/api/insurance/admin/ppt_admin_service.py

1187 lines
53 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""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", [])]