feat: suitability 公共校验服务(SUIT-001~008) + AuthContext 模型冻结(A4)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""API 依赖:鉴权上下文(架构 §5.7 · 归属校验统一在此层)。
|
||||
|
||||
AuthContext 模型在 A4 冻结(开发计划 v1.1);get_auth_context 完整实现归 B6:
|
||||
dev(app_env=development)从 X-Debug-Role / X-Debug-Actor 请求头构造,
|
||||
非 dev 环境启动时检测 debug 依赖注册即拒绝;T-01 就绪后仅替换工厂内部为 JWT 解析。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AuthContext(BaseModel):
|
||||
"""统一鉴权上下文(全部 API 依赖层的产出;service 层签名接收此类型)。"""
|
||||
|
||||
actor_id: str = Field(..., description="操作者 ID:staff_id 或 customer_id")
|
||||
roles: list[str] = Field(default_factory=list, description="角色集合,如 ['risk_officer','risk_demo']")
|
||||
customer_id: str | None = Field(None, description="customer 角色时 = 本人 customer_id;其余为空")
|
||||
|
||||
def has_role(self, *roles: str) -> bool:
|
||||
return any(r in self.roles for r in roles)
|
||||
|
||||
def is_customer(self) -> bool:
|
||||
return "customer" in self.roles
|
||||
|
||||
|
||||
def get_auth_context() -> AuthContext:
|
||||
raise NotImplementedError("implemented in B6 (X-Debug-* in dev / JWT in T-01)")
|
||||
@@ -0,0 +1,157 @@
|
||||
"""适当性校验服务(R-02 · 全系统唯一阻断点 · SUIT-001~008)。
|
||||
|
||||
规则权威:docs/PRD/附-风控规则表.md §1。返回语义(PRD FR-2):
|
||||
- is_matched = 纯等级矩阵结果(含 SUIT-006 封顶后判定)
|
||||
- blocked = 最终是否阻断(= NOT is_matched 或 SUIT-008 测评过期)
|
||||
- reasons[] 列明各规则判定;落库 customer_risk_level 记**原测评等级**
|
||||
- 数据归属校验(G-01)在 FastAPI 依赖层完成,本服务不做
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.repository.core_ro import CoreReadOnlyRepository
|
||||
from app.repository.risk_repository import RiskRepository
|
||||
from app.utils.trace import current_trace
|
||||
|
||||
TIER_ORDER = {"C1": 1, "C2": 2, "C3": 3, "C4": 4, "C5": 5}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuitabilityResult:
|
||||
customer_level: str # 原测评等级(落库用)
|
||||
effective_level: str # SUIT-006 封顶后判定等级
|
||||
product_level: str
|
||||
is_matched: bool
|
||||
blocked: bool
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
block_reason: str = "" # 阻断主因(人类可读,不含文案模板)
|
||||
|
||||
|
||||
def grade_number(grade: str) -> int:
|
||||
"""C1~C5 / R1~R5 → 1~5;非法值抛错(数据污染即 Fail-fast)。"""
|
||||
n = TIER_ORDER.get(grade)
|
||||
if n is None:
|
||||
n = TIER_ORDER.get(grade.replace("R", "C"))
|
||||
if n is None:
|
||||
raise ValueError(f"invalid risk grade: {grade!r}")
|
||||
return n
|
||||
|
||||
|
||||
def cap_by_age(level: str, age: int | None) -> tuple[str, str | None]:
|
||||
"""SUIT-006:年龄 ≥70 按最高 C3 封顶;age IS NULL 跳过并提示人工复核。"""
|
||||
if age is None:
|
||||
return level, "年龄缺失,建议人工复核"
|
||||
if age >= 70 and grade_number(level) > grade_number("C3"):
|
||||
return "C3", f"客户测评 {level} 因年龄≥70 按 C3 处理(SUIT-006)"
|
||||
return level, None
|
||||
|
||||
|
||||
def is_assessment_valid(evaluated_at: date | datetime | str | None, today: date, valid_days: int) -> bool:
|
||||
"""SUIT-008:测评有效期默认 365 天(.env 可配)。兼容 str 输入(驱动差异防御)。"""
|
||||
if evaluated_at is None:
|
||||
return False
|
||||
if isinstance(evaluated_at, str):
|
||||
evaluated_at = date.fromisoformat(evaluated_at[:10])
|
||||
if isinstance(evaluated_at, datetime):
|
||||
evaluated_at = evaluated_at.date()
|
||||
return (today - evaluated_at).days < valid_days
|
||||
|
||||
|
||||
def match_by_matrix(customer_level: str, product_level: str) -> bool:
|
||||
"""SUIT-001~005:客户等级序号 ≥ 产品等级序号方可购买。"""
|
||||
return grade_number(customer_level) >= grade_number(product_level)
|
||||
|
||||
|
||||
def check_core(
|
||||
customer: dict[str, Any],
|
||||
product: dict[str, Any],
|
||||
valid_days: int = 365,
|
||||
today: date | None = None,
|
||||
) -> SuitabilityResult:
|
||||
"""纯函数核心(无 IO):customer=core_customer+risk 合并行,product=core_product 行。
|
||||
|
||||
键约定:customer.risk_code(C1~C5)、customer.age(可 None)、
|
||||
customer.risk_evaluated_at(date/datetime);product.min_risk_code(R1~R5)。
|
||||
"""
|
||||
today = today or date.today()
|
||||
raw_level: str = customer["risk_code"]
|
||||
product_level: str = product["min_risk_code"]
|
||||
reasons: list[str] = []
|
||||
|
||||
effective_level, cap_reason = cap_by_age(raw_level, customer.get("age"))
|
||||
if cap_reason:
|
||||
reasons.append(cap_reason)
|
||||
|
||||
is_matched = match_by_matrix(effective_level, product_level)
|
||||
if is_matched:
|
||||
reasons.append(f"等级矩阵通过:{effective_level} 可购 {product_level}(SUIT-001~005)")
|
||||
else:
|
||||
reasons.append(f"等级矩阵不通过:{effective_level} 不可购 {product_level}(SUIT-001~005)")
|
||||
|
||||
expired = not is_assessment_valid(customer.get("risk_evaluated_at"), today, valid_days)
|
||||
if expired:
|
||||
reasons.append(f"风险测评已过期(有效期 {valid_days} 天),请重新测评(SUIT-008)")
|
||||
|
||||
blocked = (not is_matched) or expired
|
||||
if not is_matched:
|
||||
block_reason = f"您的风险等级为{raw_level}(判定按{effective_level}),该产品为{product_level},风险不匹配"
|
||||
elif expired:
|
||||
block_reason = "您的风险测评已过期,无法购买新产品,请重新测评"
|
||||
else:
|
||||
block_reason = ""
|
||||
|
||||
return SuitabilityResult(
|
||||
customer_level=raw_level,
|
||||
effective_level=effective_level,
|
||||
product_level=product_level,
|
||||
is_matched=is_matched,
|
||||
blocked=blocked,
|
||||
reasons=reasons,
|
||||
block_reason=block_reason,
|
||||
)
|
||||
|
||||
|
||||
def suitability_check(
|
||||
customer_id: str,
|
||||
product_id: str,
|
||||
core_ro: CoreReadOnlyRepository | None = None,
|
||||
risk_repo: RiskRepository | None = None,
|
||||
valid_days: int = 365,
|
||||
today: date | None = None,
|
||||
) -> SuitabilityResult:
|
||||
"""服务入口:查 L0 事实 → 纯函数判定 → 落 risk_suitability_log(每次校验必落)。
|
||||
|
||||
阻断时的预警单由调用方(交易网关 FR-1)生成,本函数只落校验日志。
|
||||
"""
|
||||
core_ro = core_ro or CoreReadOnlyRepository()
|
||||
risk_repo = risk_repo or RiskRepository()
|
||||
|
||||
customer = core_ro.get_customer_l0(customer_id)
|
||||
if customer is None:
|
||||
raise LookupError(f"customer not found: {customer_id}")
|
||||
product = core_ro.get_product(product_id)
|
||||
if product is None:
|
||||
raise LookupError(f"product not found: {product_id}")
|
||||
|
||||
result = check_core(customer, product, valid_days=valid_days, today=today)
|
||||
|
||||
risk_repo.insert_suitability_log(
|
||||
{
|
||||
"trace_id": current_trace(),
|
||||
"customer_id": customer_id,
|
||||
"product_id": product_id,
|
||||
"customer_risk_level": result.customer_level,
|
||||
"product_risk_level": result.product_level,
|
||||
"is_matched": int(result.is_matched),
|
||||
"is_blocked": int(result.blocked),
|
||||
"block_reason": result.block_reason or None,
|
||||
"request_ref": None,
|
||||
"profile_l1_version": None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,198 @@
|
||||
"""suitability 单测(A4 · 验收 A-8):矩阵 25 组合 + SUIT-006/008 边界 + 服务集成。"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.repository.core_ro import CoreReadOnlyRepository
|
||||
from app.repository.risk_repository import RiskRepository
|
||||
from app.service.suitability import (
|
||||
cap_by_age,
|
||||
check_core,
|
||||
grade_number,
|
||||
is_assessment_valid,
|
||||
match_by_matrix,
|
||||
suitability_check,
|
||||
)
|
||||
|
||||
TODAY = date(2026, 9, 6)
|
||||
|
||||
|
||||
def _customer(level, age=40, evaluated=TODAY - timedelta(days=90)):
|
||||
return {
|
||||
"customer_id": "C1",
|
||||
"risk_code": level,
|
||||
"age": age,
|
||||
"risk_evaluated_at": evaluated,
|
||||
}
|
||||
|
||||
|
||||
def _product(level):
|
||||
return {"product_id": "P1", "min_risk_code": level}
|
||||
|
||||
|
||||
class TestMatrix25:
|
||||
"""SUIT-001~005:C1~C5 × R1~R5 全组合。"""
|
||||
|
||||
@pytest.mark.parametrize("c", ["C1", "C2", "C3", "C4", "C5"])
|
||||
@pytest.mark.parametrize("p", ["R1", "R2", "R3", "R4", "R5"])
|
||||
def test_matrix(self, c, p):
|
||||
expect_match = grade_number(c) >= grade_number(p)
|
||||
result = check_core(_customer(c), _product(p), today=TODAY)
|
||||
assert result.is_matched is expect_match
|
||||
assert result.blocked is (not expect_match) # 测评有效期内 blocked ≡ not matched
|
||||
assert result.customer_level == c and result.effective_level == c
|
||||
|
||||
def test_a1_c1_buys_r4(self):
|
||||
"""验收 A-1 断言口径:C1+R4 → 不匹配 + 阻断 + 原因含 SUIT-001~005。"""
|
||||
result = check_core(_customer("C1"), _product("R4"), today=TODAY)
|
||||
assert result.is_matched is False and result.blocked is True
|
||||
assert "SUIT-001~005" in result.reasons[-1]
|
||||
|
||||
|
||||
class TestSUIT006AgeCap:
|
||||
def test_70yo_c5_capped_to_c3(self):
|
||||
level, reason = cap_by_age("C5", 70)
|
||||
assert level == "C3" and "SUIT-006" in reason
|
||||
|
||||
def test_69yo_c5_not_capped(self):
|
||||
assert cap_by_age("C5", 69) == ("C5", None)
|
||||
|
||||
def test_70yo_c3_unchanged(self):
|
||||
assert cap_by_age("C3", 72) == ("C3", None)
|
||||
|
||||
def test_null_age_skipped_with_review_hint(self):
|
||||
level, reason = cap_by_age("C5", None)
|
||||
assert level == "C5" and reason == "年龄缺失,建议人工复核"
|
||||
|
||||
def test_a2_70yo_c5_buys_r4_blocked(self):
|
||||
"""验收 A-2 断言口径:70 岁 C5 买 R4 → 封顶说明 + 不匹配,无过期干扰。"""
|
||||
result = check_core(_customer("C5", age=70), _product("R4"), today=TODAY)
|
||||
assert result.blocked is True and result.is_matched is False
|
||||
assert result.effective_level == "C3"
|
||||
assert any("SUIT-006" in r for r in result.reasons)
|
||||
assert not any("SUIT-008" in r for r in result.reasons)
|
||||
|
||||
|
||||
class TestSUIT008Validity:
|
||||
def test_364_days_valid(self):
|
||||
assert is_assessment_valid(TODAY - timedelta(days=364), TODAY, 365) is True
|
||||
|
||||
def test_365_days_expired(self):
|
||||
assert is_assessment_valid(TODAY - timedelta(days=365), TODAY, 365) is False
|
||||
|
||||
def test_none_evaluated_expired(self):
|
||||
assert is_assessment_valid(None, TODAY, 365) is False
|
||||
|
||||
def test_expired_but_matched_still_blocked(self):
|
||||
"""C3 买 R3 等级匹配,但测评过期 → 仍阻断(双字段语义)。"""
|
||||
customer = _customer("C3", evaluated=TODAY - timedelta(days=400))
|
||||
result = check_core(customer, _product("R3"), today=TODAY)
|
||||
assert result.is_matched is True and result.blocked is True
|
||||
assert "SUIT-008" in result.reasons[-1]
|
||||
assert "重新测评" in result.block_reason
|
||||
|
||||
def test_custom_valid_days(self):
|
||||
customer = _customer("C3", evaluated=TODAY - timedelta(days=100))
|
||||
assert is_assessment_valid(customer["risk_evaluated_at"], TODAY, 90) is False
|
||||
|
||||
|
||||
class TestCheckService:
|
||||
"""suitability_check 服务集成(sqlite 内存库):落日志 + 原等级落库。"""
|
||||
|
||||
@pytest.fixture()
|
||||
def repos(self):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE core_customer (
|
||||
customer_id VARCHAR(64) PRIMARY KEY, display_name VARCHAR(64),
|
||||
age INTEGER, occupation VARCHAR(64), phone_mask VARCHAR(16),
|
||||
tenant_id VARCHAR(32), open_date DATE, is_active INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE core_customer_risk (
|
||||
id INTEGER PRIMARY KEY, customer_id VARCHAR(64), risk_code VARCHAR(2),
|
||||
is_authoritative INTEGER DEFAULT 1, evaluated_at DATE,
|
||||
source VARCHAR(32) DEFAULT 'risk_questionnaire'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE core_product (
|
||||
product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128),
|
||||
product_type VARCHAR(8), min_risk_code VARCHAR(2),
|
||||
industry_code VARCHAR(16), fee_rate NUMERIC(6,4),
|
||||
is_open INTEGER DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE risk_suitability_log (
|
||||
id INTEGER PRIMARY KEY, trace_id VARCHAR(64), customer_id VARCHAR(64),
|
||||
product_id VARCHAR(64), customer_risk_level VARCHAR(2),
|
||||
product_risk_level VARCHAR(2), is_matched INTEGER, is_blocked INTEGER,
|
||||
block_reason VARCHAR(512), request_ref VARCHAR(64),
|
||||
profile_l1_version INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_customer (customer_id, display_name, age, open_date)"
|
||||
" VALUES ('CUST-1', '客户·测**', 70, '2020-01-01')"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at)"
|
||||
" VALUES ('CUST-1', 'C5', :evaluated)"
|
||||
),
|
||||
{"evaluated": TODAY - timedelta(days=90)},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_product (product_id, product_name, product_type, min_risk_code)"
|
||||
" VALUES ('PROD-R4', '测试R4产品', 'stock', 'R4')"
|
||||
)
|
||||
)
|
||||
core_ro = CoreReadOnlyRepository(engine=engine)
|
||||
risk_repo = RiskRepository(engine=engine)
|
||||
yield core_ro, risk_repo, engine
|
||||
engine.dispose()
|
||||
|
||||
def test_service_blocked_and_log_written(self, repos):
|
||||
core_ro, risk_repo, engine = repos
|
||||
result = suitability_check(
|
||||
"CUST-1", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY
|
||||
)
|
||||
assert result.blocked is True and result.effective_level == "C3"
|
||||
# 落库 customer_risk_level = 原测评等级 C5(封顶只进 reasons,PRD FR-2)
|
||||
row = engine.connect().execute(
|
||||
text(
|
||||
"SELECT customer_risk_level, product_risk_level, is_matched, is_blocked"
|
||||
" FROM risk_suitability_log"
|
||||
)
|
||||
).first()
|
||||
assert row[0] == "C5" and row[1] == "R4" and row[2] == 0 and row[3] == 1
|
||||
|
||||
def test_service_unknown_customer_raises(self, repos):
|
||||
core_ro, risk_repo, _ = repos
|
||||
with pytest.raises(LookupError):
|
||||
suitability_check("NOPE", "PROD-R4", core_ro=core_ro, risk_repo=risk_repo, today=TODAY)
|
||||
Reference in New Issue
Block a user