diff --git a/docs/项目框架设计/表设计/01-mysql-共用底座.sql b/docs/项目框架设计/表设计/01-mysql-共用底座.sql index 5242071..6020553 100644 --- a/docs/项目框架设计/表设计/01-mysql-共用底座.sql +++ b/docs/项目框架设计/表设计/01-mysql-共用底座.sql @@ -180,15 +180,35 @@ CREATE TABLE risk_suitability_log ( trace_id VARCHAR(64) NOT NULL, customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, - customer_risk_level CHAR(2) NOT NULL, - product_risk_level CHAR(2) NOT NULL, + product_name VARCHAR(128) NULL COMMENT '判定时产品名快照', + customer_risk_level CHAR(2) NOT NULL COMMENT 'L0 C1~C5', + product_risk_level CHAR(2) NOT NULL COMMENT '产品最低 R1~R5', + investor_category ENUM('ordinary','professional','professional_pending') NOT NULL DEFAULT 'ordinary', + match_result ENUM( + 'allowed','allowed_with_disclosure','forbidden', + 'risk_expired','professional_exempt' + ) NOT NULL COMMENT '与 Core check_suitability 输出一致', + mismatch_type ENUM( + 'none','risk_level','risk_expired','age_branch_confirm', + 'min_subscribe','not_found','professional_exempt' + ) NOT NULL DEFAULT 'none' COMMENT '阻断/特殊处理原因分类', is_matched TINYINT(1) NOT NULL, is_blocked TINYINT(1) NOT NULL DEFAULT 0, - block_reason VARCHAR(512) NULL, - request_ref VARCHAR(64) NULL, + requires_disclosure TINYINT(1) NOT NULL DEFAULT 0, + needs_branch_confirm TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'FM-01 年龄≥70 买 R3+', + risk_was_expired TINYINT(1) NOT NULL DEFAULT 0 COMMENT '判定时风评是否已过期', + block_reason VARCHAR(512) NULL COMMENT '对人可读原因', + block_response_code VARCHAR(32) NULL COMMENT 'API 机器码,如 SUIT_RISK_MISMATCH', + check_source ENUM('r02_trade','r02_chat','c11_inquiry','manual') NOT NULL DEFAULT 'r02_trade', + actor_id VARCHAR(64) NOT NULL COMMENT '发起者 customer_id / staff_id / svc-trade-suitability', + request_ref VARCHAR(64) NULL COMMENT '交易单号 / session_id', profile_l1_version INT UNSIGNED NULL, + rule_refs JSON NULL COMMENT '依据规则,如 ["JR-AST-012","FM-03"]', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_trace (trace_id), KEY idx_customer (customer_id, created_at), KEY idx_product (product_id, created_at), - KEY idx_blocked (is_blocked, created_at) -) ENGINE=InnoDB COMMENT='【交换】适当性记录 · 风控写 / 客户+代理人读'; + KEY idx_blocked (is_blocked, created_at), + KEY idx_match (match_result, created_at), + KEY idx_mismatch (mismatch_type, created_at) +) ENGINE=InnoDB COMMENT='【交换】适当性记录 · 风控写 / 客户+代理人读 · 字段契约见 07-risk_suitability_log说明.md'; diff --git a/docs/项目框架设计/表设计/07-risk_suitability_log说明.md b/docs/项目框架设计/表设计/07-risk_suitability_log说明.md new file mode 100644 index 0000000..a1dd125 --- /dev/null +++ b/docs/项目框架设计/表设计/07-risk_suitability_log说明.md @@ -0,0 +1,114 @@ +# risk_suitability_log · P0 字段契约 + +> **用途:** 风控 Agent(R-02)写入;客户 / 代理人 Agent **只读**。 +> **原则:** 一行记录 = 一次 `CoreReadOnlyRepository.check_suitability()` 判定 + 审计上下文。 +> **SQL:** [01-mysql-共用底座.sql](./01-mysql-共用底座.sql) +> **映射代码:** `app/model/suitability.py` → `build_suitability_log_row()` + +--- + +## 1. 与 Core 计算器的关系 + +```text +Core.check_suitability(customer_id, product_id) + ↓ 返回 dict(match_result / mismatch_type / blocked …) +build_suitability_log_row(check, trace_id, actor_id, …) + ↓ +INSERT risk_suitability_log ← jinrong_agent 库 + ↓ 只读 +客户 Agent(本人)· 代理人 Agent(名下客户) +``` + +**不在此表存:** C×R 规则本身(在 `jinrong_core.core_suitability_rule`)。 + +--- + +## 2. 字段 ↔ check_suitability 映射 + +| 表字段 | 来源 | 说明 | +| --- | --- | --- | +| `customer_risk_level` | `customer_risk_code` | L0 正式 C 档 | +| `product_risk_level` | `product_risk_code` | 产品最低 R 档 | +| `product_name` | `product_name` | 快照,避免日后改名 | +| `investor_category` | `investor_category` | 判定时投资者类别 | +| `match_result` | `match_result` | 见下表枚举 | +| `mismatch_type` | `mismatch_type` | 见下表枚举 | +| `is_matched` | `matched` | 是否视为可匹配 | +| `is_blocked` | `blocked` | 是否阻断请求 | +| `requires_disclosure` | `requires_disclosure` | 是否需风险揭示书 | +| `needs_branch_confirm` | `needs_branch_confirm` | FM-01 网点确认 | +| `risk_was_expired` | `risk_is_expired` | FM-03 风评过期 | +| `block_reason` | `reason` | 对人可读 | +| `block_response_code` | `block_response_code` | API 机器码 | + +--- + +## 3. match_result 枚举 + +| 值 | 含义 | 典型场景 | +| --- | --- | --- | +| `allowed` | 完全匹配 | C2 买 R2 | +| `allowed_with_disclosure` | 可买但需揭示书 | C3 买 R4 | +| `forbidden` | 等级不配 | C1 买 R4 | +| `risk_expired` | 风评过期 | FM-03 | +| `professional_exempt` | 专业投资者豁免 | 已认定 professional | + +--- + +## 4. mismatch_type 枚举 + +| 值 | 含义 | is_blocked 常见 | +| --- | --- | --- | +| `none` | 无特殊问题 | 0 | +| `risk_level` | C/R 矩阵禁止 | 1 | +| `risk_expired` | 风评过期 | 1 | +| `age_branch_confirm` | 高龄买 R3+ 需网点 | 1 | +| `min_subscribe` | 起购金额不足(P0 预留) | 1 | +| `not_found` | 客户或产品不存在 | 1 | +| `professional_exempt` | 豁免路径 | 0 | + +--- + +## 5. block_response_code(P0) + +| 代码 | 条件 | +| --- | --- | +| `SUIT_OK` | 匹配且可继续 | +| `SUIT_NEED_DISCLOSURE` | 需签署风险揭示书 | +| `SUIT_RISK_MISMATCH` | C/R 不配 | +| `SUIT_RISK_EXPIRED` | 风评过期 | +| `SUIT_AGE_CONFIRM` | 需网点确认(FM-01) | +| `SUIT_PROFESSIONAL_EXEMPT` | 专业投资者豁免 | +| `SUIT_NOT_FOUND` | 客户或产品不存在 | +| `SUIT_MIN_SUBSCRIBE` | 起购不足(预留) | + +--- + +## 6. check_source / actor_id + +| check_source | 触发场景 | actor_id 示例 | +| --- | --- | --- | +| `r02_trade` | 交易前钩子 | `svc-trade-suitability` | +| `r02_chat` | 风控 Agent 对话判定 | `STAFF-30001` | +| `c11_inquiry` | 客户问「能否买」 | `CUST-DEMO-A` | +| `manual` | 人工补录 | 员工 staff_id | + +--- + +## 7. 读写权限(不变) + +| Agent | 权限 | +| --- | --- | +| 风控 | **写** | +| 客户 | **读**(仅本人) | +| 代理人 | **读**(仅名下客户) | +| 数据分析 | 不用 | + +--- + +## 8. P0 验收 + +- [ ] R-02 写入行与 `check_suitability` 返回值逐项一致 +- [ ] 阻断时同时写 `audit_log`(同 `trace_id`) +- [ ] 客户 Agent 只读本人;代理人只读 `customer_advisor_rel` 名下 +- [ ] `blocked=true` 仅 R-02 / 交易钩子 API 可对外返回 diff --git a/tests/_ddl.py b/tests/_ddl.py index c9fe11a..d216ccb 100644 --- a/tests/_ddl.py +++ b/tests/_ddl.py @@ -17,20 +17,35 @@ SQLITE_TABLES: dict[str, str] = { "core_customer": """ CREATE TABLE core_customer ( customer_id VARCHAR(64) PRIMARY KEY, display_name VARCHAR(128), age INTEGER, - occupation VARCHAR(64), open_date DATE, is_active TINYINT DEFAULT 1) + occupation VARCHAR(64), open_date DATE, is_active TINYINT DEFAULT 1, + is_hnw TINYINT DEFAULT 0, service_tier VARCHAR(16) DEFAULT 'normal') """, "core_customer_risk": """ CREATE TABLE core_customer_risk ( - customer_id VARCHAR(64), risk_code VARCHAR(8), evaluated_at TIMESTAMP) + customer_id VARCHAR(64), risk_code VARCHAR(8), evaluated_at TIMESTAMP, + expires_at TIMESTAMP, is_authoritative TINYINT DEFAULT 1, + source VARCHAR(32) DEFAULT 'risk_questionnaire', + investor_category VARCHAR(32) DEFAULT 'ordinary', + questionnaire_score INTEGER, max_loss_tolerance_pct REAL, + investment_goal VARCHAR(128), investment_horizon VARCHAR(16), + professional_approved_at TIMESTAMP) """, "core_customer_advisor": """ CREATE TABLE core_customer_advisor ( advisor_id VARCHAR(64), customer_id VARCHAR(64), rel_status VARCHAR(16)) """, + "core_suitability_rule": """ + CREATE TABLE core_suitability_rule ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_risk_code VARCHAR(8), product_risk_code VARCHAR(8), + match_result VARCHAR(32), rule_ref VARCHAR(32) DEFAULT 'JR-AST-012') + """, "core_product": """ CREATE TABLE core_product ( product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128), - min_risk_code VARCHAR(8), product_type VARCHAR(32)) + min_risk_code VARCHAR(8), product_type VARCHAR(32), + min_subscribe_amount DECIMAL DEFAULT 1.00, term_days INTEGER, + requires_disclosure TINYINT DEFAULT 0) """, "core_holding": """ CREATE TABLE core_holding ( @@ -92,11 +107,19 @@ SQLITE_TABLES: dict[str, str] = { """, "risk_suitability_log": f""" CREATE TABLE risk_suitability_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64), customer_id VARCHAR(64), - product_id VARCHAR(64), customer_risk_level VARCHAR(8), product_risk_level VARCHAR(8), - is_matched TINYINT, is_blocked TINYINT, block_reason VARCHAR(512), + id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64) NOT NULL, + customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, + product_name VARCHAR(128), + customer_risk_level VARCHAR(8) NOT NULL, product_risk_level VARCHAR(8) NOT NULL, + investor_category VARCHAR(32) DEFAULT 'ordinary', + match_result VARCHAR(32) NOT NULL, mismatch_type VARCHAR(32) DEFAULT 'none', + is_matched TINYINT, is_blocked TINYINT DEFAULT 0, + requires_disclosure TINYINT DEFAULT 0, needs_branch_confirm TINYINT DEFAULT 0, + risk_was_expired TINYINT DEFAULT 0, + block_reason VARCHAR(512), block_response_code VARCHAR(32), + check_source VARCHAR(16) DEFAULT 'r02_trade', actor_id VARCHAR(64), request_ref VARCHAR(64), profile_l1_version VARCHAR(32), - created_at {_TS}) + rule_refs TEXT, created_at {_TS}) """, "customer_profile_l3": f""" CREATE TABLE customer_profile_l3 ( diff --git a/tests/test_risk_api.py b/tests/test_risk_api.py index aecd152..ee43c43 100644 --- a/tests/test_risk_api.py +++ b/tests/test_risk_api.py @@ -73,7 +73,7 @@ def env(): ) conn.execute( text( - "INSERT INTO core_product VALUES" + "INSERT INTO core_product (product_id, product_name, min_risk_code, product_type) VALUES" " ('PROD-161725', '科技成长主题', 'R4', 'mixed')," " ('PROD-510300', '沪深300指数', 'R3', 'index')" )