"""产品推介材料业务编排服务。""" from __future__ import annotations import mimetypes from datetime import UTC, datetime from pathlib import Path from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.promotion_material import ( PromotionDeliveryRequest, PromotionGenerationRequest, PromotionInputsUpdate, PromotionReviewRequest, PromotionTaskCreate, ) from app.core.config import get_settings from app.core.contracts import RequestContext from app.core.errors import GenericResourceNotFoundError, ValidationAgentError from app.core.promotion_material_contracts import MANDATORY_RISK_DISCLOSURE from app.model.audit import InteractionAudit from app.model.promotion_material import ( PromotionAttachment, PromotionComplianceCheck, PromotionDeliveryRecord, PromotionInputSnapshot, PromotionMaterialTask, PromotionMaterialVersion, PromotionReviewRecord, ) from app.service.api_transaction_service import ApiTransactionService from app.service.authorization_service import AuthorizationService from app.service.promotion_compliance import ( ComplianceFinding, PromotionComplianceChecker, ) from app.service.promotion_pdf_adapter import PromotionPdfAdapter from app.service.promotion_performance import ( create_performance_chart, file_hash, parse_performance_file, ) from app.service.promotion_poster_renderer import PromotionPosterRenderer from app.service.promotion_renderer import PromotionPptxRenderer class PromotionMaterialService: def __init__(self, session: AsyncSession | None = None) -> None: self.session = session settings = get_settings() self.storage_root = Path(settings.promotion_material_storage_dir).resolve() self.max_photo_size = settings.promotion_max_photo_size_bytes self.max_performance_size = settings.promotion_max_performance_file_size_bytes self.compliance = PromotionComplianceChecker() self.renderer = PromotionPptxRenderer() self.poster_renderer = PromotionPosterRenderer() self.pdf_adapter = PromotionPdfAdapter( enabled=settings.promotion_pdf_enabled, converter_path=settings.promotion_pdf_converter_path, ) async def create_task( self, payload: PromotionTaskCreate, context: RequestContext, key: str | None ) -> dict[str, Any]: await self._require(context, "promotion:write") if key is None: raise ValidationAgentError("必须提供 Idempotency-Key") body = payload.model_dump(mode="json") return await ApiTransactionService().execute( context, "promotion-material:create-task", key, body, lambda session: self._create_task_tx(session, payload, context), ) async def update_inputs( self, task_no: str, payload: PromotionInputsUpdate, context: RequestContext, key: str | None, ) -> dict[str, Any]: await self._require(context, "promotion:write") if key is None: raise ValidationAgentError("必须提供 Idempotency-Key") return await ApiTransactionService().execute( context, f"promotion-material:update-inputs:{task_no}", key, payload.model_dump(mode="json"), lambda session: self._update_inputs_tx(session, task_no, payload, context), ) async def add_attachment( self, task_no: str, attachment_type: str, filename: str, media_type: str, payload: bytes, context: RequestContext, key: str | None, ) -> dict[str, Any]: await self._require(context, "promotion:write") if key is None: raise ValidationAgentError("必须提供 Idempotency-Key") if not payload: raise ValidationAgentError("上传文件不能为空") body = {"attachment_type": attachment_type, "filename": filename, "media_type": media_type, "sha256": file_hash(payload)} return await ApiTransactionService().execute( context, f"promotion-material:add-attachment:{task_no}", key, body, lambda session: self._add_attachment_tx( session, task_no, attachment_type, filename, media_type, payload, context ), ) async def generate( self, task_no: str, payload: PromotionGenerationRequest, context: RequestContext, key: str | None, ) -> dict[str, Any]: await self._require(context, "promotion:write") if key is None: raise ValidationAgentError("必须提供 Idempotency-Key") return await ApiTransactionService().execute( context, f"promotion-material:generate:{task_no}", key, payload.model_dump(mode="json"), lambda session: self._generate_tx(session, task_no, payload, context), ) async def compliance_checks( self, task_no: str, context: RequestContext ) -> dict[str, Any]: await self._require(context, "promotion:read") session = self._session() rows = (await session.execute(select(PromotionComplianceCheck).where( PromotionComplianceCheck.task_no == task_no ).order_by(PromotionComplianceCheck.id))).scalars().all() return {"code": 0, "message": "ok", "data": { "task_no": task_no, "findings": [self._compliance_dict(row) for row in rows], }} async def get_task(self, task_no: str, context: RequestContext) -> dict[str, Any]: await self._require(context, "promotion:read") session = self._session() task = await session.scalar(select(PromotionMaterialTask).where( PromotionMaterialTask.task_no == task_no )) if task is None: raise GenericResourceNotFoundError("推介材料任务不存在") is_advisor = "advisor" in context.roles if is_advisor: delivery = await session.scalar(select(PromotionDeliveryRecord).where( PromotionDeliveryRecord.task_no == task_no, PromotionDeliveryRecord.advisor_id == int(context.user_id), PromotionDeliveryRecord.status == "sent", )) if delivery is None: raise GenericResourceNotFoundError("该材料尚未发送给当前投顾") version = await session.scalar(select(PromotionMaterialVersion).where( PromotionMaterialVersion.task_no == task_no, PromotionMaterialVersion.status.in_(("approved", "sent")), ).order_by(PromotionMaterialVersion.version_no.desc())) if version is None: return {"code": 0, "message": "ok", "data": { "task_no": task_no, "product_name": task.product_name, "status": task.status, "material_version": None, }} return {"code": 0, "message": "ok", "data": { "task_no": task_no, "product_name": task.product_name, "status": task.status, "material_version": { "id": version.id, "version_no": version.version_no, "style_code": version.style_code, "status": version.status, "pptx_path": version.pptx_path, "pdf_path": version.pdf_path, "poster_path": version.poster_path, "chart_paths": version.chart_paths, }, }} async def review( self, task_no: str, payload: PromotionReviewRequest, context: RequestContext, key: str | None, ) -> dict[str, Any]: await self._require(context, "promotion:review") if key is None: raise ValidationAgentError("必须提供 Idempotency-Key") return await ApiTransactionService().execute( context, f"promotion-material:review:{task_no}", key, payload.model_dump(mode="json"), lambda session: self._review_tx(session, task_no, payload, context), ) async def deliver( self, task_no: str, payload: PromotionDeliveryRequest, context: RequestContext, key: str | None, ) -> dict[str, Any]: await self._require(context, "promotion:deliver") if key is None: raise ValidationAgentError("必须提供 Idempotency-Key") return await ApiTransactionService().execute( context, f"promotion-material:deliver:{task_no}", key, payload.model_dump(mode="json"), lambda session: self._deliver_tx(session, task_no, payload, context), ) async def _create_task_tx( self, session: AsyncSession, payload: PromotionTaskCreate, context: RequestContext ) -> dict[str, Any]: task_no = await self._next_task_no(session) now = self._now() session.add(PromotionMaterialTask( task_no=task_no, product_code=payload.product_code, product_name=payload.product_name, material_title=payload.material_title, style_code=payload.style_code, output_formats=payload.output_formats, template_version="promotion-v1", status="draft", created_by=int(context.user_id), created_at=now, updated_at=now, )) self._audit(session, context, "promotion.task_created", {"task_no": task_no}) return {"code": 0, "message": "ok", "data": {"task_no": task_no, "status": "draft"}} async def _update_inputs_tx( self, session: AsyncSession, task_no: str, payload: PromotionInputsUpdate, context: RequestContext, ) -> dict[str, Any]: task = await self._task(session, task_no) latest = await session.scalar(select(func.max(PromotionInputSnapshot.version_no)).where( PromotionInputSnapshot.task_no == task_no )) version_no = int(latest or 0) + 1 now = self._now() values = payload.model_dump(mode="json") session.add(PromotionInputSnapshot( task_no=task_no, version_no=version_no, product_info=values["product_info"], manager_info=values["manager_info"], team_info=values["team_info"], strategy_info=values["strategy_info"], fee_structure=values["fee_structure"], performance_info=values["performance_info"], risk_disclosure=values["risk_disclosure"], source_notes=values["source_notes"], created_by=int(context.user_id), created_at=now, )) task.status = "input_ready" task.updated_at = now self._audit(session, context, "promotion.inputs_updated", { "task_no": task_no, "input_version": version_no, }) return {"code": 0, "message": "ok", "data": { "task_no": task_no, "input_version": version_no, "status": task.status, }} async def _add_attachment_tx( self, session: AsyncSession, task_no: str, attachment_type: str, filename: str, media_type: str, payload: bytes, context: RequestContext, ) -> dict[str, Any]: task = await self._task(session, task_no) self._validate_attachment( attachment_type, filename, media_type, len(payload), max_photo_size=self.max_photo_size, max_performance_size=self.max_performance_size, ) digest = file_hash(payload) duplicate = await session.scalar(select(PromotionAttachment).where( PromotionAttachment.task_no == task_no, PromotionAttachment.file_hash == digest, )) if duplicate is not None: return {"code": 0, "message": "ok", "data": { "attachment_id": duplicate.id, "duplicate": True, }} extension = Path(filename).suffix.lower() destination = self.storage_root / task_no / attachment_type / f"{digest}{extension}" destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(payload) parsed_metadata: dict[str, Any] = {} try: if attachment_type == "manager_photo": from PIL import Image with Image.open(destination) as image: parsed_metadata = {"width": image.width, "height": image.height, "format": image.format} elif attachment_type == "performance_data": parsed_metadata = parse_performance_file(destination) except Exception as exc: destination.unlink(missing_ok=True) raise ValidationAgentError("附件内容无法解析或校验失败") from exc now = self._now() attachment = PromotionAttachment( task_no=task_no, attachment_type=attachment_type, filename=Path(filename).name, media_type=( media_type or mimetypes.guess_type(filename)[0] or "application/octet-stream" ), file_hash=digest, size_bytes=len(payload), file_path=str(destination), parsed_metadata=parsed_metadata, uploaded_by=int(context.user_id), created_at=now, ) session.add(attachment) task.updated_at = now await session.flush() self._audit(session, context, "promotion.attachment_uploaded", { "task_no": task_no, "attachment_id": attachment.id, "attachment_type": attachment_type, "sha256": digest, }) return {"code": 0, "message": "ok", "data": { "attachment_id": attachment.id, "attachment_type": attachment_type, "sha256": digest, "size_bytes": len(payload), }} async def _generate_tx( self, session: AsyncSession, task_no: str, payload: PromotionGenerationRequest, context: RequestContext, ) -> dict[str, Any]: task = await self._task(session, task_no) snapshot = await session.scalar(select(PromotionInputSnapshot).where( PromotionInputSnapshot.task_no == task_no ).order_by(PromotionInputSnapshot.version_no.desc())) if snapshot is None: return {"code": 422, "message": "请先补充结构化资料", "data": {}} inputs = self._snapshot_dict(snapshot) attachments = (await session.execute(select(PromotionAttachment).where( PromotionAttachment.task_no == task_no ).order_by(PromotionAttachment.id))).scalars().all() performance_attachment = next( (item for item in attachments if item.attachment_type == "performance_data"), None, ) inputs["performance_info"] = { **inputs["performance_info"], "performance_attachment_id": performance_attachment.id if performance_attachment is not None else None, } input_findings = self.compliance.check_inputs(inputs) if input_findings: self._save_findings(session, task_no, None, input_findings) task.status = "compliance_failed" task.updated_at = self._now() return {"code": 422, "message": "输入资料未通过合规校验", "data": { "task_no": task_no, "findings": [self._finding_dict(item) for item in input_findings], }} task.status = "generating" task.updated_at = self._now() version_no = int(await session.scalar(select(func.max( PromotionMaterialVersion.version_no )).where(PromotionMaterialVersion.task_no == task_no)) or 0) + 1 draft = self._build_draft(task, inputs) pptx_path = self.storage_root / task_no / "materials" / f"v{version_no}.pptx" poster_path = self.storage_root / task_no / "materials" / f"v{version_no}-poster.png" photo_path = next( ( item.file_path for item in attachments if item.attachment_type == "manager_photo" ), None, ) try: chart_paths = self._build_charts(task_no, version_no, list(attachments), inputs) self.renderer.render(draft, pptx_path, photo_path=photo_path, chart_paths=chart_paths) requested_formats = payload.output_formats or task.output_formats if "poster" in requested_formats: self.poster_renderer.render( draft, poster_path, photo_path=photo_path, chart_paths=chart_paths, ) pdf_path: str | None = None if "pdf" in requested_formats: target_pdf = self.storage_root / task_no / "materials" / f"v{version_no}.pdf" pdf_path = self.pdf_adapter.convert(pptx_path, target_pdf) if pdf_path is None: raise RuntimeError("PDF 转换未启用或转换器路径未配置") except (RuntimeError, OSError, ValueError) as exc: task.status = "failed" task.updated_at = self._now() return {"code": 503, "message": str(exc), "data": {"task_no": task_no}} version = PromotionMaterialVersion( task_no=task_no, input_snapshot_id=snapshot.id, version_no=version_no, style_code=task.style_code, template_version=task.template_version, prompt_version="deterministic-v1", draft_json=draft, pptx_path=str(pptx_path), pdf_path=pdf_path, poster_path=str(poster_path) if "poster" in requested_formats else None, chart_paths=chart_paths, status="generated", generated_by=int(context.user_id), created_at=self._now(), ) session.add(version) await session.flush() findings = self.compliance.check_draft(draft, inputs) if not findings: findings = [ComplianceFinding( "overall.pass", "确定性合规校验通过", "generated_text", "pass", None, "可提交人工审核", )] self._save_findings(session, task_no, version.id, findings) blocking = any(item.severity == "block" for item in findings) version.status = "compliance_failed" if blocking else "pending_review" task.status = version.status task.updated_at = self._now() self._audit(session, context, "promotion.material_generated", { "task_no": task_no, "material_version_id": version.id, "status": task.status, }) return {"code": 0 if not blocking else 422, "message": "ok" if not blocking else "生成内容未通过合规校验", "data": { "task_no": task_no, "material_version_id": version.id, "status": task.status, "pptx_path": str(pptx_path), "pdf_path": pdf_path, "poster_path": ( str(poster_path) if "poster" in requested_formats else None ), "chart_paths": chart_paths, "findings": [self._finding_dict(item) for item in findings], }} async def _review_tx( self, session: AsyncSession, task_no: str, payload: PromotionReviewRequest, context: RequestContext, ) -> dict[str, Any]: task = await self._task(session, task_no) version = await session.scalar(select(PromotionMaterialVersion).where( PromotionMaterialVersion.id == payload.material_version_id, PromotionMaterialVersion.task_no == task_no, ).with_for_update()) if version is None: raise GenericResourceNotFoundError("材料版本不存在") if version.status != "pending_review": return {"code": 422, "message": "当前材料版本不在待审核状态", "data": {}} checks = (await session.execute(select(PromotionComplianceCheck).where( PromotionComplianceCheck.material_version_id == version.id, PromotionComplianceCheck.severity == "block", ))).scalars().all() if checks: return {"code": 422, "message": "存在阻断级合规问题", "data": {}} now = self._now() session.add(PromotionReviewRecord( task_no=task_no, material_version_id=version.id, reviewer_id=int(context.user_id), decision=payload.decision, comment=payload.comment, created_at=now, )) version.status = "approved" if payload.decision == "approved" else "rejected" task.status = ( "approved" if payload.decision == "approved" else "draft" if payload.decision == "revision_requested" else "rejected" ) task.updated_at = now self._audit(session, context, "promotion.material_reviewed", { "task_no": task_no, "material_version_id": version.id, "decision": payload.decision, }) return {"code": 0, "message": "ok", "data": { "task_no": task_no, "material_version_id": version.id, "status": task.status, }} async def _deliver_tx( self, session: AsyncSession, task_no: str, payload: PromotionDeliveryRequest, context: RequestContext, ) -> dict[str, Any]: task = await self._task(session, task_no) version = await session.scalar(select(PromotionMaterialVersion).where( PromotionMaterialVersion.id == payload.material_version_id, PromotionMaterialVersion.task_no == task_no, ).with_for_update()) if version is None: raise GenericResourceNotFoundError("材料版本不存在") if version.status != "approved": return {"code": 422, "message": "只有审核通过的材料可以发送", "data": {}} if payload.delivery_channel != "internal_record": return {"code": 422, "message": "首版只支持内部发送记录,未接入外部投顾端", "data": {}} now = self._now() for advisor_id in payload.advisor_ids: existing = await session.scalar(select(PromotionDeliveryRecord).where( PromotionDeliveryRecord.material_version_id == version.id, PromotionDeliveryRecord.advisor_id == advisor_id, )) if existing is None: session.add(PromotionDeliveryRecord( task_no=task_no, material_version_id=version.id, advisor_id=advisor_id, delivery_channel=payload.delivery_channel, status="sent", created_by=int(context.user_id), created_at=now, sent_at=now, )) else: existing.status = "sent" existing.sent_at = now version.status = "sent" task.status = "sent" task.updated_at = now self._audit(session, context, "promotion.material_delivered", { "task_no": task_no, "material_version_id": version.id, "advisor_ids": payload.advisor_ids, }) return {"code": 0, "message": "ok", "data": { "task_no": task_no, "material_version_id": version.id, "advisor_ids": payload.advisor_ids, "status": "sent", }} @staticmethod def _build_draft(task: PromotionMaterialTask, inputs: dict[str, Any]) -> dict[str, Any]: product = inputs["product_info"] manager = inputs["manager_info"] team = inputs["team_info"] strategy = inputs["strategy_info"] fees = inputs["fee_structure"] performance = inputs["performance_info"] risk = inputs["risk_disclosure"] fee_text = ";".join(f"{key}: {value}" for key, value in fees.items() if value) performance_text = ( f"数据截至 {performance.get('as_of_date') or '未填写'}。" f"区间 {performance.get('history_months') or '未填写'} 个月。" f"产品收益 {performance.get('product_return') or '未提供'}。" f"最大回撤 {performance.get('max_drawdown') or '未提供'}," f"波动率 {performance.get('volatility') or '未提供'}," f"夏普比率 {performance.get('sharpe_ratio') or '未提供'}。" ) return { "title": task.material_title, "subtitle": f"{task.product_name} 产品推介材料", "style_code": task.style_code, "chapters": [ {"title": task.material_title, "body": "仅供合规审核和专业投顾使用"}, {"title": "产品基本信息", "body": ( f"基金类型:{product['fund_type']}\n运作方式:{product['operation_mode']}\n" f"产品状态:{product.get('product_status', '')}\n" f"投资目标:{product['investment_objective']}\n" f"业绩比较基准:{product.get('benchmark') or '未提供'}" )}, {"title": "管理人及投研团队", "body": ( f"管理人:{manager['management_company']}\n登记编码:{manager['registration_code']}\n" f"基金经理:{manager['manager_name']}\n" f"从业年限:{manager.get('employment_years') or '未提供'}\n" "实际投资管理经验:" f"{manager.get('investment_management_experience') or '未提供'}\n" f"团队建设:{team['team_description']}\n" f"投研能力:{team.get('research_capability') or '未提供'}" )}, {"title": "投资范围、策略与限制", "body": ( f"投资范围:{strategy['investment_scope']}\n策略:{strategy['strategy']}\n" f"投资限制:{strategy['restrictions']}\n" f"工具属性:{strategy.get('index_tool_attribute') or '不适用'}" )}, {"title": "费用结构", "body": fee_text or "未提供费用结构"}, {"title": "基金经理历史业绩", "body": ( "以下内容为基金经理代表产品历史业绩,不代表本基金历史业绩。\n" + performance_text ), "chart_index": 0}, {"title": "产品、基准与代表产品曲线", "body": ( "曲线由上传的结构化业绩数据生成,具体口径以来源文件和审核结果为准。" ), "chart_index": 0}, {"title": "收益与风险指标", "body": ( f"{performance_text}\n" "最大回撤反映历史区间内的最大跌幅,波动率反映收益变化幅度," "夏普比率用于辅助观察单位风险对应的超额收益。" )}, {"title": "风险揭示", "body": ( MANDATORY_RISK_DISCLOSURE + "。\n" + "\n".join(f"特别风险:{item}" for item in risk.get("special_risks", [])) + ( f"\n补充说明:{risk['additional_notes']}" if risk.get("additional_notes") else "" ) )}, {"title": "资料来源与版本", "body": ( "本材料基于运营人员提交的结构化资料、附件和固定模板生成。" "生成后须经人工审核,未经审核不得对外使用。" )}, ], } def _build_charts( self, task_no: str, version_no: int, attachments: list[PromotionAttachment], inputs: dict[str, Any], ) -> list[str]: performance_attachment = next( (item for item in attachments if item.attachment_type == "performance_data"), None ) if performance_attachment is None: return [] data = parse_performance_file(performance_attachment.file_path) destination = self.storage_root / task_no / "charts" / f"v{version_no}-performance.png" return [create_performance_chart( data, destination, title=f"{inputs['product_info'].get('fund_type', '')} 业绩曲线" )] async def _task(self, session: AsyncSession, task_no: str) -> PromotionMaterialTask: task = await session.scalar(select(PromotionMaterialTask).where( PromotionMaterialTask.task_no == task_no ).with_for_update()) if task is None: raise GenericResourceNotFoundError("推介材料任务不存在") return task def _session(self) -> AsyncSession: if self.session is None: raise RuntimeError("查询服务缺少数据库会话") return self.session @staticmethod async def _require(context: RequestContext, permission: str) -> None: await AuthorizationService.require(context, permission) async def _next_task_no(self, session: AsyncSession) -> str: today = datetime.now().strftime("%Y%m%d") count = await session.scalar(select(func.count()).select_from(PromotionMaterialTask).where( PromotionMaterialTask.task_no.like(f"PM-{today}-%") )) return f"PM-{today}-{int(count or 0) + 1:04d}" @staticmethod def _snapshot_dict(snapshot: PromotionInputSnapshot) -> dict[str, Any]: return { "product_info": snapshot.product_info, "manager_info": snapshot.manager_info, "team_info": snapshot.team_info, "strategy_info": snapshot.strategy_info, "fee_structure": snapshot.fee_structure, "performance_info": snapshot.performance_info, "risk_disclosure": snapshot.risk_disclosure, "source_notes": snapshot.source_notes, } @staticmethod def _validate_attachment( attachment_type: str, filename: str, media_type: str, size: int, *, max_photo_size: int, max_performance_size: int, ) -> None: limits = { "manager_photo": (max_photo_size, {".jpg", ".jpeg", ".png", ".webp"}), "performance_data": (max_performance_size, {".csv", ".xlsx", ".xlsm"}), "source_evidence": (30 * 1024 * 1024, {".pdf", ".docx", ".xlsx", ".csv"}), "template_file": (50 * 1024 * 1024, {".pptx"}), } if attachment_type not in limits: raise ValidationAgentError("不支持的附件类型") maximum, extensions = limits[attachment_type] if size > maximum: raise ValidationAgentError("附件超过大小限制") if Path(filename).suffix.lower() not in extensions: raise ValidationAgentError("附件扩展名与附件类型不匹配") if attachment_type == "manager_photo" and not media_type.startswith("image/"): raise ValidationAgentError("基金经理头像必须是图片") @staticmethod def _finding_dict(item: ComplianceFinding) -> dict[str, Any]: return { "rule_code": item.rule_code, "rule_name": item.rule_name, "scope": item.scope, "severity": item.severity, "hit_text": item.hit_text, "suggestion": item.suggestion, } @staticmethod def _compliance_dict(row: PromotionComplianceCheck) -> dict[str, Any]: return { "id": row.id, "material_version_id": row.material_version_id, "rule_code": row.rule_code, "rule_name": row.rule_name, "scope": row.scope, "severity": row.severity, "hit_text": row.hit_text, "suggestion": row.suggestion, } def _save_findings( self, session: AsyncSession, task_no: str, version_id: int | None, findings: list[ComplianceFinding], ) -> None: now = self._now() for item in findings: session.add(PromotionComplianceCheck( task_no=task_no, material_version_id=version_id, rule_code=item.rule_code, rule_name=item.rule_name, scope=item.scope, severity=item.severity, hit_text=item.hit_text, suggestion=item.suggestion, created_at=now, )) @staticmethod def _audit(session: AsyncSession, context: RequestContext, action: str, detail: dict[str, Any]) -> None: session.add(InteractionAudit( actor_type="user", actor_id=int(context.user_id), target_customer_id=None, session_id=None, portal=context.portal, action_type=action, detail={**detail, "trace_id": context.trace_id}, created_at=datetime.now(UTC).replace(tzinfo=None), )) @staticmethod def _now() -> datetime: return datetime.now(UTC).replace(tzinfo=None)