Files
group_fqcd_jr/app/service/suitability_service.py
T

306 lines
14 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
"""公共投顾适当性校验服务。
适当性是治理边界,不属于任何一个业务 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 等级匹配,但仍强制风险揭示、确认与录音,且不能绕过测评有效期与审计。
2026-09-09 21:55:37 +08:00
"""
import re
from collections.abc import Callable, Mapping
2026-09-09 21:55:37 +08:00
from datetime import UTC, datetime
from typing import Any, Literal
2026-09-09 21:55:37 +08:00
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
2026-09-09 21:55:37 +08:00
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError
2026-09-09 21:55:37 +08:00
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"})
# 《个人投资者适当性管理指南》第十二条匹配矩阵(客户等级 → 可购买的产品等级)。
#
# 矩阵与第十四条的**第 2、3 款**一致:只禁止"低两个等级及以上"的越级,低一个等级要看
# 档位(C1→R2、C2→R3 直接可买;C3→R4、C4→R5 需签风险揭示书)。真正冲突的是第十四条
# **第 1 款**"必须大于或等于"—— 它与同一条第 2、3 款自相矛盾。
#
# 2026-09-11 业务裁定:**客服回答按矩阵,风控扫描保留 C ≥ R**。
# 理由是两者的职责不同:客服要给客户一个与知识库(`POL-AST-012`)一致的"能不能买",
# 矩阵才是客户看得见的口径;风控要发现的是"越级成交且留痕不全",用更严的 C ≥ R 去
# 事后核查。改之前两边是**同一个 Agent 自相矛盾**:问"C1 能买什么产品"答"R1、R2 可买"
# (矩阵),问"C1 能买这只 R2 吗"却答"不能购买"(C ≥ R)。
MATRIX_ALLOWED: dict[int, frozenset[int]] = {
1: frozenset({1, 2}),
2: frozenset({1, 2, 3}),
3: frozenset({1, 2, 3, 4}),
4: frozenset({1, 2, 3, 4, 5}),
5: frozenset({1, 2, 3, 4, 5}),
}
# 矩阵里标"⚠️ 需签署风险揭示书"的档位,即第十五条豁免档。
MATRIX_NEEDS_DISCLOSURE: dict[int, frozenset[int]] = {
3: frozenset({4}),
4: frozenset({5}),
}
_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):
"""服务端权威风险画像(只读汇总,不含测评问卷原文)。"""
2026-09-09 21:55:37 +08:00
model_config = ConfigDict(extra="forbid", frozen=True)
customer_id: str
customer_risk_level: int | None = None
2026-09-09 21:55:37 +08:00
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)
2026-09-09 21:55:37 +08:00
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
2026-09-09 21:55:37 +08:00
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
2026-09-09 21:55:37 +08:00
) -> 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)
2026-09-11 14:44:29 +08:00
async def authority_for_customer(self, customer_id: int) -> RiskAuthorityProfile:
"""Expose the read-only risk authority for other advisory services."""
return await self._load_authority_profile(str(customer_id))
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,
)
# 按第十二条匹配矩阵裁决(见 MATRIX_ALLOWED 的说明):不再用"C < R 即拒绝",
# 那样会把矩阵允许的 C1→R2、C2→R3 以及豁免档 C3→R4、C4→R5 一起拒掉。
level = profile.customer_risk_level
product_level = request.product_risk_level
if product_level not in MATRIX_ALLOWED.get(level, frozenset()):
return self._denied("RISK_LEVEL_MISMATCH", request, profile)
needs_disclosure = product_level in MATRIX_NEEDS_DISCLOSURE.get(level, frozenset())
required_disclosure = request.product_requires_disclosure or needs_disclosure
2026-09-09 21:55:37 +08:00
return SuitabilityDecision(
allowed=True,
reason_code="SUITABLE_WITH_DISCLOSURE" if needs_disclosure else "SUITABLE",
2026-09-09 21:55:37 +08:00
required_disclosure=required_disclosure,
requires_confirmation=request.requires_confirmation or required_disclosure,
requires_recording=required_disclosure or request.requires_confirmation,
customer_risk_level=level,
professional_investor=False,
assessment_valid_until=profile.valid_until,
2026-09-09 21:55:37 +08:00
)
async def evaluate_and_audit(
self, request: SuitabilityToolInput, context: RequestContext, *, now: datetime | None = None
2026-09-09 21:55:37 +08:00
) -> SuitabilityDecision:
decision = await self.evaluate(request, context, now=now)
# 适当性决定是受监管业务决策,拒绝和通过都留痕;只记录权威来源摘要,
# 不保存测评问卷原文,也不保存调用方自报的任何等级。
async with self._session_factory() as session, session.begin():
2026-09-09 21:55:37 +08:00
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
),
2026-09-09 21:55:37 +08:00
"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:
2026-09-09 21:55:37 +08:00
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,
2026-09-09 21:55:37 +08:00
)
async def suitability_tool_handler(
arguments: SuitabilityToolInput, context: RequestContext
) -> dict[str, Any]:
decision = await SuitabilityService().evaluate_and_audit(arguments, context)
2026-09-09 21:55:37 +08:00
return decision.model_dump(mode="json")