"""公共投顾适当性校验服务。 适当性是治理边界,不属于任何一个业务 Agent。该服务只读客户/产品风险信息, 返回不可变决定;拒绝决定必须留下审计记录,且不会修改交易或产品数据。 合规要点(B2 修复): 1. **客户风险等级只能来自服务端权威来源**:取 ``fin_risk_assessment`` 中该客户最新一条 测评(``assessed_at`` 最新),调用方传入的 ``customer_risk_level`` 一律不接受 (DTO ``extra="forbid"`` 直接拒绝伪造字段)。 2. **测评有效期以权威记录的 ``valid_until`` 为准**,调用方不能自报过期时间;测评缺失或 过期一律失败关闭,不做静默降级。 3. **专业投资者身份来自 ``sys_user``**(``is_professional_investor`` 且 ``professional_investor_status='已认定'``),而非调用方参数;已认定的专业投资者可豁免 C/R 等级匹配,但仍强制风险揭示、确认与录音,且不能绕过测评有效期与审计。 """ import re from collections.abc import Callable, Mapping from datetime import UTC, datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import text from app.core.contracts import RequestContext from app.core.errors import ForbiddenAgentError from app.infrastructure.db import SessionFactory from app.model.audit import InteractionAudit PROFESSIONAL_INVESTOR_CERTIFIED = "已认定" RISK_LEVEL_SOURCE = "fin_risk_assessment" PROFESSIONAL_INVESTOR_SOURCE = "sys_user" CUSTOMER_SCOPE_EXEMPT_ROLES = frozenset({"admin", "super_admin"}) _INVESTOR_TYPE_PATTERN = re.compile(r"^C([1-5])$") AuthorityReason = Literal[ "AUTHORITY_OK", "CUSTOMER_NOT_FOUND", "ASSESSMENT_MISSING", "ASSESSMENT_EXPIRED", "RISK_LEVEL_INVALID", ] # 只读查询:客户专业投资者身份(sys_user)+ 最新一条风险测评(fin_risk_assessment)。 # 不读取 answers 问卷原文,避免把敏感测评明细带入服务层。 _AUTHORITY_SQL = text( """ SELECT u.is_professional_investor, u.professional_investor_status, a.investor_type, a.assessed_at, a.valid_until FROM sys_user u LEFT JOIN fin_risk_assessment a ON a.id = ( SELECT x.id FROM fin_risk_assessment x WHERE x.customer_id = u.id ORDER BY x.assessed_at DESC, x.id DESC LIMIT 1 ) WHERE u.id = :customer_id """ ) class RiskAuthorityProfile(BaseModel): """服务端权威风险画像(只读汇总,不含测评问卷原文)。""" model_config = ConfigDict(extra="forbid", frozen=True) customer_id: str customer_risk_level: int | None = None professional_investor: bool = False assessed_at: datetime | None = None valid_until: datetime | None = None authority_reason: AuthorityReason = "AUTHORITY_OK" class SuitabilityToolInput(BaseModel): """ToolExecutor 使用的严格输入模型,避免业务 Agent 自行拼接规则。 调用方只能声明“给谁、买什么等级的产品、是否需要揭示/确认”, 风险等级与测评有效期一律由服务端权威来源解析。 """ model_config = ConfigDict(extra="forbid", frozen=True) customer_id: str = Field(min_length=1, max_length=20, pattern=r"^[0-9]+$") product_risk_level: int = Field(ge=1, le=5) product_requires_disclosure: bool = True requires_confirmation: bool = False class SuitabilityDecision(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) allowed: bool reason_code: str required_disclosure: bool requires_confirmation: bool requires_recording: bool customer_risk_level: int | None = None risk_level_source: str = RISK_LEVEL_SOURCE professional_investor: bool = False assessment_valid_until: datetime | None = None def _as_utc(value: object) -> datetime | None: """库内 DATETIME 为 UTC naive,统一规范化为带时区,便于安全比较。""" if not isinstance(value, datetime): return None return value if value.tzinfo is not None else value.replace(tzinfo=UTC) def _risk_level_from_investor_type(investor_type: object) -> int | None: if not isinstance(investor_type, str): return None matched = _INVESTOR_TYPE_PATTERN.match(investor_type.strip().upper()) return int(matched.group(1)) if matched is not None else None class SuitabilityService: """执行 C1-C5/R1-R5 的公共、只读适当性规则。""" def __init__(self, *, session_factory: Callable[[], Any] | None = None) -> None: self._session_factory: Callable[[], Any] = session_factory or SessionFactory async def evaluate( self, request: SuitabilityToolInput, context: RequestContext, *, now: datetime | None = None ) -> SuitabilityDecision: current = now or datetime.now(UTC) self._assert_customer_scope(request.customer_id, context) profile = await self._load_authority_profile(request.customer_id) return self._decide(request, profile, current) async def _load_authority_profile(self, customer_id: str) -> RiskAuthorityProfile: async with self._session_factory() as session: result = await session.execute(_AUTHORITY_SQL, {"customer_id": int(customer_id)}) row: Mapping[str, Any] | None = result.mappings().first() if row is None: return RiskAuthorityProfile( customer_id=customer_id, authority_reason="CUSTOMER_NOT_FOUND" ) level = _risk_level_from_investor_type(row["investor_type"]) valid_until = _as_utc(row["valid_until"]) if row["investor_type"] is None: reason: AuthorityReason = "ASSESSMENT_MISSING" elif level is None: reason = "RISK_LEVEL_INVALID" elif valid_until is None: # 有效期缺失视为不可用,绝不按“长期有效”放行。 reason = "ASSESSMENT_EXPIRED" else: reason = "AUTHORITY_OK" return RiskAuthorityProfile( customer_id=customer_id, customer_risk_level=level, professional_investor=( bool(row["is_professional_investor"]) and str(row["professional_investor_status"]) == PROFESSIONAL_INVESTOR_CERTIFIED ), assessed_at=_as_utc(row["assessed_at"]), valid_until=valid_until, authority_reason=reason, ) def _decide( self, request: SuitabilityToolInput, profile: RiskAuthorityProfile, current: datetime ) -> SuitabilityDecision: if profile.authority_reason == "CUSTOMER_NOT_FOUND": return self._denied("CUSTOMER_NOT_FOUND", request, profile) if profile.authority_reason == "ASSESSMENT_MISSING": return self._denied("ASSESSMENT_MISSING", request, profile) if profile.authority_reason == "RISK_LEVEL_INVALID": return self._denied("RISK_LEVEL_INVALID", request, profile) if profile.valid_until is None or profile.valid_until <= current: # 测评过期即拒绝:专业投资者也不能绕过有效期。 return self._denied("ASSESSMENT_EXPIRED", request, profile) if profile.customer_risk_level is None: return self._denied("ASSESSMENT_MISSING", request, profile) if profile.professional_investor: # 已认定专业投资者可豁免等级匹配,但必须揭示、确认并录音留痕。 return SuitabilityDecision( allowed=True, reason_code="SUITABLE_PROFESSIONAL_INVESTOR", required_disclosure=True, requires_confirmation=True, requires_recording=True, customer_risk_level=profile.customer_risk_level, professional_investor=True, assessment_valid_until=profile.valid_until, ) if profile.customer_risk_level < request.product_risk_level: return self._denied("RISK_LEVEL_MISMATCH", request, profile) required_disclosure = request.product_requires_disclosure return SuitabilityDecision( allowed=True, reason_code="SUITABLE", required_disclosure=required_disclosure, requires_confirmation=request.requires_confirmation or required_disclosure, requires_recording=required_disclosure or request.requires_confirmation, customer_risk_level=profile.customer_risk_level, professional_investor=False, assessment_valid_until=profile.valid_until, ) async def evaluate_and_audit( self, request: SuitabilityToolInput, context: RequestContext, *, now: datetime | None = None ) -> SuitabilityDecision: decision = await self.evaluate(request, context, now=now) # 适当性决定是受监管业务决策,拒绝和通过都留痕;只记录权威来源摘要, # 不保存测评问卷原文,也不保存调用方自报的任何等级。 async with self._session_factory() as session, session.begin(): actor_id = int(context.user_id) if context.user_id.isdecimal() else None session.add(InteractionAudit( actor_type="agent", actor_id=actor_id, portal=context.portal, action_type="suitability.checked", detail={ "trace_id": context.trace_id, "status": "allowed" if decision.allowed else "denied", "reason_code": decision.reason_code, "customer_id": request.customer_id, "customer_risk_level": decision.customer_risk_level, "risk_level_source": decision.risk_level_source, "professional_investor": decision.professional_investor, "professional_investor_source": PROFESSIONAL_INVESTOR_SOURCE, "assessment_valid_until": ( decision.assessment_valid_until.isoformat() if decision.assessment_valid_until is not None else None ), "product_risk_level": request.product_risk_level, }, created_at=datetime.now(UTC).replace(tzinfo=None), )) return decision @staticmethod def _assert_customer_scope(customer_id: str, context: RequestContext) -> None: """公共鉴权:除管理员外不得查询他人风险测评。""" if set(context.roles).intersection(CUSTOMER_SCOPE_EXEMPT_ROLES): return if customer_id == context.user_id or customer_id in context.customer_ids: return raise ForbiddenAgentError("不能查询该客户的风险测评") @staticmethod def _denied( reason_code: str, request: SuitabilityToolInput, profile: RiskAuthorityProfile ) -> SuitabilityDecision: return SuitabilityDecision( allowed=False, reason_code=reason_code, required_disclosure=request.product_requires_disclosure, requires_confirmation=True, requires_recording=True, customer_risk_level=profile.customer_risk_level, professional_investor=profile.professional_investor, assessment_valid_until=profile.valid_until, ) async def suitability_tool_handler( arguments: SuitabilityToolInput, context: RequestContext ) -> dict[str, Any]: decision = await SuitabilityService().evaluate_and_audit(arguments, context) return decision.model_dump(mode="json")