diff --git a/app/gateway/trade_gateway.py b/app/gateway/trade_gateway.py index 94efebd..8be96d2 100644 --- a/app/gateway/trade_gateway.py +++ b/app/gateway/trade_gateway.py @@ -141,13 +141,22 @@ def submit_trade( trade_id=trade_id, req=req, rule_id=result.rule_id, - detail={"block_reason": result.block_reason, "reasons": list(result.reasons)}, + detail={ + "block_reason": result.block_reason, + "block_response_code": result.block_response_code, + "reasons": list(result.reasons), + }, ) return { "blocked": True, "trade_id": trade_id, + "match_result": result.match_result, + "mismatch_type": result.mismatch_type, + "requires_disclosure": result.requires_disclosure, + "needs_branch_confirm": result.needs_branch_confirm, "block_reason": result.block_reason, "block_response_code": result.block_response_code, + "rule_refs": result.rule_refs, "reasons": list(result.reasons), "advice": ADVICE, "notice": RECORDED_NOTICE, diff --git a/docs/项目框架设计/演示SOP-风控模块.md b/docs/项目框架设计/演示SOP-风控模块.md index e12222b..f601854 100644 --- a/docs/项目框架设计/演示SOP-风控模块.md +++ b/docs/项目框架设计/演示SOP-风控模块.md @@ -40,7 +40,7 @@ ERROR 1050(表已存在)中止且残留未清。脚本化方式已含双库 (两种方式均不含 Neo4j 同步;风控演示不依赖 Neo4j,需要时手动跑 `scripts/sync/sync_neo4j.py`。) **重灌完成标志**:`seed-aml-list.sql` 输出 8 条名单;`prepare_risk_demo.sql` 输出 7 行测评 -(6 行 90 天、CUST-1004 保持过期);`sync_advisor_rel` 输出 upserted 28 rows。 +(6 行测评 90 天前 / expires_at 剩余 275 天,CUST-1004 保持过期演示 FM-03);`sync_advisor_rel` 输出 upserted 33 rows(33 客户口径,AL-01 起)。 ## 3. 启动与入口 @@ -63,8 +63,8 @@ Swagger 逐条执行;预期响应与库内核验点如下(`trade_id` 以实 | # | 操作(Swagger) | 预期 | | --- | --- | --- | -| A-1 | `POST /api/simulate/trade`,customer 头 CUST-1001,body:CUST-1001 / PROD-161725 / subscribe / 10000 | `blocked=true`,`block_reason` 含 SUIT-001;库核验:`core_trade` 无该 trade_id;`risk_suitability_log.is_blocked=1`;`risk_alert`(trade_id 定位)suitability/pending;audit `suitability_blocked`(agent_type=platform);订阅端收到推送且 trace 与响应头一致 | -| A-2 | 同上,risk_demo 头,CUST-4001 / PROD-161725 / subscribe / 20000 | `blocked=true`,reasons 含 SUIT-006(≥70 按 C3)与 SUIT-003,**无 SUIT-008** | +| A-1 | `POST /api/simulate/trade`,customer 头 CUST-1001,body:CUST-1001 / PROD-161725 / subscribe / 10000 | `blocked=true`,`block_response_code=SUIT_RISK_MISMATCH`(AL-05 起 main 契约机器码,SUIT-001 退役);库核验:`core_trade` 无该 trade_id;`risk_suitability_log.is_blocked=1`;`risk_alert`(trade_id 定位)suitability/pending;audit `suitability_blocked`(agent_type=platform);订阅端收到推送且 trace 与响应头一致 | +| A-2 | 同上,risk_demo 头,CUST-4001 / PROD-161725 / subscribe / 20000 | `blocked=true`,`block_response_code=SUIT_AGE_CONFIRM`(AL-05 起 FM-01 网点确认替换 SUIT-006 封顶:70 岁买 R4≥R3 一律阻断待网点当面确认),`needs_branch_confirm=true` | | A-3 | 同上,CUST-3001 / PROD-510300 / subscribe / 500000 | `blocked=false`,`triggered_rules=["RISK-001","RISK-002"]`,预警单 score=70/pending;`core_trade` 落库;audit `trade_accepted`;**订阅端实时打印推送** | | A-4 | 同上,CUST-9527 / PROD-510300 / subscribe / 1000,连发 4 笔 | 第 3 笔触发 RISK-003;第 4 笔 `alert_ids` 与第 3 笔相同(并入同单),该客户 freq 类单数=1,payload.events 长度=2 | | A-5 | 同上,CUST-1002 / PROD-005828 / subscribe / 100 | `aml_hit=true`;aml 独立单 score=95(按本笔 trade_id 定位);L3 `monitor_tier=high`;compliance 头 `GET /api/risk/alerts` 仅见 aml 单且含该单;`core_customer.is_active` 仍=1(不冻户) | diff --git a/scripts/demo/prepare_risk_demo.sql b/scripts/demo/prepare_risk_demo.sql index 13e9fb9..75c7389 100644 --- a/scripts/demo/prepare_risk_demo.sql +++ b/scripts/demo/prepare_risk_demo.sql @@ -1,7 +1,9 @@ -- ============================================================================= -- 风控演示数据准备(PRD §10.2 · 每次 reset.ps1 重建全库后需重跑) --- 用途:刷新演示客户的风险测评日期,否则 SUIT-008(测评有效期 365 天) --- 会阻断所有演示交易;CUST-1004 保留过期,用于演示 SUIT-008 阻断路径。 +-- 用途:刷新演示客户的风险测评有效期,否则 FM-03(风评过期,expires_at 驱动) +-- 会阻断所有演示交易;CUST-1004 保留过期,用于演示 FM-03 过期阻断路径。 +-- AL-08 对齐 main 契约:有效期由 core_customer_risk.expires_at 数据驱动 +-- (evaluated_at+365 天口径与 settings.risk_assessment_valid_days 已退役)。 -- 依赖:scripts/core/reset.ps1 已执行(jinrong_core 已建库灌种子) -- 验收对照:docs/PRD/PRD-风控监测Agent.md §8 A-1~A-5 -- ============================================================================= @@ -9,12 +11,17 @@ USE jinrong_core; -- A-1 CUST-1001(C1) / A-2 CUST-4001(70岁C5) / A-3 CUST-3001(C3) / 其余为通用演示客户 -UPDATE core_customer_risk SET evaluated_at = CURDATE() - INTERVAL 90 DAY +-- 刷新为「测评 90 天前、有效期剩余 275 天」(合计 365 天,保持原语义节奏) +UPDATE core_customer_risk +SET evaluated_at = CURDATE() - INTERVAL 90 DAY, + expires_at = CURDATE() + INTERVAL 275 DAY WHERE customer_id IN ('CUST-1001','CUST-1002','CUST-1003','CUST-3001','CUST-4001','CUST-9527'); --- 校验(执行后应看到 6 行 evaluated_at 为 90 天前;CUST-1004 保持 2025-01-10 过期) -SELECT customer_id, risk_code, evaluated_at, - DATEDIFF(CURDATE(), evaluated_at) AS days_since_eval +-- 校验(执行后应看到 6 行:测评 90 天前、expires_at 剩余 275 天; +-- CUST-1004 保持种子过期日,演示 FM-03 阻断路径) +SELECT customer_id, risk_code, evaluated_at, expires_at, + DATEDIFF(CURDATE(), evaluated_at) AS days_since_eval, + DATEDIFF(expires_at, CURDATE()) AS days_until_expire FROM core_customer_risk WHERE customer_id IN ('CUST-1001','CUST-1002','CUST-1003','CUST-3001','CUST-4001','CUST-9527','CUST-1004') ORDER BY customer_id; diff --git a/tests/test_integration_risk.py b/tests/test_integration_risk.py index c09ab52..526e3b7 100644 --- a/tests/test_integration_risk.py +++ b/tests/test_integration_risk.py @@ -149,7 +149,7 @@ def _assert_a4_clean(env): ) -# ---------- A-1:C1 申购 R4 → SUIT-001 阻断(含 trace 一致性) ---------- +# ---------- A-1:C1 申购 R4 → 矩阵 forbidden 阻断(含 trace 一致性) ---------- def test_a1_suit001_blocked_with_trace_consistency(iclient, risk_demo_env): @@ -165,7 +165,8 @@ def test_a1_suit001_blocked_with_trace_consistency(iclient, risk_demo_env): assert r.status_code == 200 body = r.json() assert body["blocked"] is True - assert "SUIT-001" in body["block_reason"] # C1 仅可购 R1 + # AL-05 main 契约:机器码 SUIT_RISK_MISMATCH(SUIT-001 退役) + assert body["block_response_code"] == "SUIT_RISK_MISMATCH" assert r.headers["X-Trace-Id"] == trace # 中间件透传(PRD §7.5 全链路环①) trade_id = body["trade_id"] assert trade_id.startswith("TRD-TEST-") @@ -204,11 +205,11 @@ def test_a1_audit_input_summary_json(iclient, risk_demo_env): assert summary["product_id"] == "PROD-161725" assert summary["trade_type"] == "subscribe" assert summary["amount"] == "10000" - assert "SUIT-001" in summary["block_reason"] + assert summary["block_response_code"] == "SUIT_RISK_MISMATCH" # AL-05 main 契约 assert isinstance(summary["reasons"], list) and summary["reasons"] -# ---------- A-2:70 岁 C5 → SUIT-006 封顶 + SUIT-003,无 SUIT-008 ---------- +# ---------- A-2:70 岁 C5 买 R4 → FM-01 网点确认阻断(替换 SUIT-006 封顶) ---------- def test_a2_age70_cap_suit006_no_suit008(iclient, risk_demo_env): @@ -222,10 +223,10 @@ def test_a2_age70_cap_suit006_no_suit008(iclient, risk_demo_env): assert r.status_code == 200 body = r.json() assert body["blocked"] is True - reasons = body["reasons"] - assert any("SUIT-006" in x for x in reasons), reasons # ≥70 按 C3 封顶 - assert any("SUIT-003" in x for x in reasons), reasons # C3 < R4 不匹配 - assert not any("SUIT-008" in x for x in reasons), reasons # 测评已刷新,不得干扰 + # AL-05 main 契约:≥70 买 R3+ 一律阻断待网点当面确认(FM-01 替换 SUIT-006 封顶) + assert body["block_response_code"] == "SUIT_AGE_CONFIRM" + assert body["needs_branch_confirm"] is True + assert any("网点当面确认" in x for x in body["reasons"]), body["reasons"] trade_id = body["trade_id"] assert _core_one(env, "SELECT * FROM core_trade WHERE trade_id = :t", t=trade_id) is None _state["a2_trade_id"] = trade_id @@ -446,4 +447,5 @@ def test_missing_trace_header_generates_one(iclient): def test_settings_thresholds_loaded(): """冒烟:阈值配置与冻结规则一致(.env 未覆盖时)。""" assert settings.risk_large_amount == 500000 - assert settings.risk_assessment_valid_days == 365 + # risk_assessment_valid_days 已随 AL-05 退役(有效期由 core_customer_risk.expires_at 驱动) + assert not hasattr(settings, "risk_assessment_valid_days")