feat: suitability 公共校验服务(SUIT-001~008) + AuthContext 模型冻结(A4)

This commit is contained in:
2026-09-06 15:18:35 +08:00
parent 4eb8b462cd
commit 3c07de67b7
3 changed files with 383 additions and 0 deletions
+28
View File
@@ -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)")
+157
View File
@@ -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
+198
View File
@@ -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)