Files
group_fqcd_jr/app/service/promotion_material_service.py
T
2026-09-14 01:07:43 +08:00

772 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""产品推介材料业务编排服务。"""
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)
normalized_media_type = (
media_type
or mimetypes.guess_type(filename)[0]
or "application/octet-stream"
)
# 部分 Windows 浏览器或代理会把图片上传为通用二进制类型;
# 扩展名和 PIL 内容校验仍会继续约束文件,不能只信任请求头。
if (
attachment_type == "manager_photo"
and not normalized_media_type.startswith("image/")
):
normalized_media_type = mimetypes.guess_type(filename)[0] or normalized_media_type
self._validate_attachment(
attachment_type,
filename,
normalized_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=normalized_media_type,
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)