2026-09-07 17:34:32 +08:00
|
|
|
|
"""适当性校验服务(R-02 · 全系统唯一阻断点 · main 矩阵契约)。
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
AL-05 对齐 main 基准(2026-09-07):
|
|
|
|
|
|
- 判定内核由「SUIT-001~008 纯函数矩阵」整体替换为 main 的
|
|
|
|
|
|
``CoreReadOnlyRepository.check_suitability``(C×R 数据驱动,L0 权威 =
|
|
|
|
|
|
jinrong_core.core_suitability_rule);check_core / cap_by_age /
|
|
|
|
|
|
match_by_matrix / is_assessment_valid 删除(SUIT-006 封顶被 FM-01 网点确认
|
|
|
|
|
|
替换;SUIT-008 被 FM-03 expires_at 数据驱动替换,settings.risk_assessment_valid_days
|
|
|
|
|
|
退役)。
|
|
|
|
|
|
- 返回 SuitabilityResult 扩展 main 新契约字段(match_result 五值 / mismatch_type
|
|
|
|
|
|
七值 / requires_disclosure / needs_branch_confirm / block_response_code /
|
|
|
|
|
|
rule_refs);同时保留旧字段供既有调用方零改动读取:
|
|
|
|
|
|
- ``rule_id`` = block_response_code(机器码别名,SUIT-00n 体系退役后的过渡口);
|
|
|
|
|
|
正式规则依据请读 ``rule_refs``(JR-AST-012 / FM-01 / FM-03 / JR-AST-PRO)。
|
|
|
|
|
|
- ``effective_level`` = customer_level(SUIT-006 封顶退役后二者恒等)。
|
|
|
|
|
|
- ``reasons`` = [reason](main 契约单条对人可读原因)。
|
|
|
|
|
|
- NotFound 语义变更:客户/产品缺失不再抛 NotFoundError,返回 forbidden/not_found
|
|
|
|
|
|
结构(main 契约)。
|
|
|
|
|
|
- 落库走 ``app/model/suitability.build_suitability_log_row`` 统一映射;
|
|
|
|
|
|
check_source 参数化(网关 r02_trade / 对话 Tool r02_chat / C-11 询问
|
|
|
|
|
|
c11_inquiry / 人工 manual),actor_id 由调用方透传。
|
|
|
|
|
|
- 数据归属校验(G-01)在 FastAPI 依赖层完成,本服务不做。
|
2026-09-06 15:18:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
from app.model.suitability import CheckSource, build_suitability_log_row, compute_rule_refs
|
2026-09-06 15:18:35 +08:00
|
|
|
|
from app.repository.core_ro import CoreReadOnlyRepository
|
|
|
|
|
|
from app.repository.risk_repository import RiskRepository
|
2026-09-06 15:37:05 +08:00
|
|
|
|
from app.utils.trace import current_trace, new_trace
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class SuitabilityResult:
|
2026-09-07 17:34:32 +08:00
|
|
|
|
"""R-02 判定结果(main 契约字段 + 旧字段兼容层)。"""
|
|
|
|
|
|
|
|
|
|
|
|
# ---- main 新契约 ----
|
|
|
|
|
|
match_result: str # allowed / allowed_with_disclosure / forbidden / risk_expired / professional_exempt
|
|
|
|
|
|
mismatch_type: str # none / risk_level / risk_expired / age_branch_confirm / min_subscribe / not_found / professional_exempt
|
2026-09-06 15:18:35 +08:00
|
|
|
|
is_matched: bool
|
|
|
|
|
|
blocked: bool
|
2026-09-07 17:34:32 +08:00
|
|
|
|
requires_disclosure: bool = False
|
|
|
|
|
|
needs_branch_confirm: bool = False # FM-01 年龄≥70 买 R3+
|
|
|
|
|
|
block_reason: str = "" # 对人可读原因(= reason)
|
|
|
|
|
|
block_response_code: str = "" # API 机器码,如 SUIT_RISK_MISMATCH
|
|
|
|
|
|
rule_refs: list[str] = field(default_factory=list) # ["JR-AST-012","FM-03",...]
|
|
|
|
|
|
investor_category: str = "ordinary"
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
# ---- 兼容旧调用方(语义映射,见 module docstring) ----
|
|
|
|
|
|
customer_level: str = "" # 原测评等级(= check.customer_risk_code)
|
|
|
|
|
|
product_level: str = "" # 产品最低适配等级(= check.product_risk_code)
|
|
|
|
|
|
effective_level: str = "" # SUIT-006 退役后恒 = customer_level
|
|
|
|
|
|
reasons: list[str] = field(default_factory=list) # = [reason]
|
|
|
|
|
|
rule_id: str = "" # = block_response_code(机器码别名;SUIT-00n 退役)
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_check(cls, check: dict[str, Any]) -> "SuitabilityResult":
|
|
|
|
|
|
"""core_ro.check_suitability 结果 → SuitabilityResult(含兼容映射)。"""
|
|
|
|
|
|
reason = check.get("reason") or ""
|
|
|
|
|
|
code = check.get("block_response_code") or ""
|
|
|
|
|
|
customer_level = check.get("customer_risk_code") or ""
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
match_result=check.get("match_result") or "forbidden",
|
|
|
|
|
|
mismatch_type=check.get("mismatch_type") or "not_found",
|
|
|
|
|
|
is_matched=bool(check.get("matched")),
|
|
|
|
|
|
blocked=bool(check.get("blocked")),
|
|
|
|
|
|
requires_disclosure=bool(check.get("requires_disclosure")),
|
|
|
|
|
|
needs_branch_confirm=bool(check.get("needs_branch_confirm")),
|
|
|
|
|
|
block_reason=reason,
|
|
|
|
|
|
block_response_code=code,
|
|
|
|
|
|
rule_refs=compute_rule_refs(check),
|
|
|
|
|
|
investor_category=check.get("investor_category") or "ordinary",
|
|
|
|
|
|
customer_level=customer_level,
|
|
|
|
|
|
product_level=check.get("product_risk_code") or "",
|
|
|
|
|
|
effective_level=customer_level,
|
|
|
|
|
|
reasons=[reason] if reason else [],
|
|
|
|
|
|
rule_id=code,
|
2026-09-06 15:37:05 +08:00
|
|
|
|
)
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def suitability_check(
|
|
|
|
|
|
customer_id: str,
|
|
|
|
|
|
product_id: str,
|
|
|
|
|
|
core_ro: CoreReadOnlyRepository | None = None,
|
|
|
|
|
|
risk_repo: RiskRepository | None = None,
|
2026-09-07 17:34:32 +08:00
|
|
|
|
check_source: CheckSource = "r02_trade",
|
|
|
|
|
|
actor_id: str | None = None,
|
2026-09-06 15:37:05 +08:00
|
|
|
|
request_ref: str | None = None,
|
2026-09-06 15:18:35 +08:00
|
|
|
|
) -> SuitabilityResult:
|
2026-09-07 17:34:32 +08:00
|
|
|
|
"""服务入口:core_ro.check_suitability 矩阵判定 → 落 risk_suitability_log(每次校验必落)。
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
- 签名兼容:前五参位置调用(trade_gateway / risk.py / chat_tools)不受影响;
|
|
|
|
|
|
valid_days / today 参数随 SUIT-008 退役删除(有效期改由
|
|
|
|
|
|
core_customer_risk.expires_at 数据驱动)。
|
|
|
|
|
|
- NotFound:客户/产品缺失返回 forbidden/not_found 结构,不再抛异常。
|
|
|
|
|
|
- check_source:网关 r02_trade / 对话 Tool r02_chat / C-11 c11_inquiry /
|
|
|
|
|
|
manual;actor_id 由调用方透传(网关 "svc-trade-suitability",直调 API 传
|
|
|
|
|
|
auth.actor_id),缺省 "SYSTEM"。
|
|
|
|
|
|
- 阻断时的预警单由调用方(交易网关 FR-1)生成,本函数只落校验日志。
|
2026-09-06 15:18:35 +08:00
|
|
|
|
"""
|
2026-09-07 17:34:32 +08:00
|
|
|
|
core = core_ro or CoreReadOnlyRepository()
|
|
|
|
|
|
repo = risk_repo or RiskRepository()
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
check = core.check_suitability(customer_id, product_id)
|
|
|
|
|
|
result = SuitabilityResult.from_check(check)
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
2026-09-07 17:34:32 +08:00
|
|
|
|
row = build_suitability_log_row(
|
|
|
|
|
|
check,
|
|
|
|
|
|
trace_id=current_trace() or new_trace(),
|
|
|
|
|
|
actor_id=actor_id or "SYSTEM",
|
|
|
|
|
|
check_source=check_source,
|
|
|
|
|
|
request_ref=request_ref,
|
|
|
|
|
|
profile_l1_version=None,
|
2026-09-06 15:18:35 +08:00
|
|
|
|
)
|
2026-09-07 17:34:32 +08:00
|
|
|
|
repo.insert_suitability_log(row)
|
2026-09-06 15:18:35 +08:00
|
|
|
|
return result
|