Files
group_fqcd_jr/docs/25-风控模块代码评审报告.md
T
lzf_0626 575b4c2baa 客服适当性改按第十二条匹配矩阵(更正我上一轮的复核结论)
你说得对:C ≥ R 是风控的口径,客服要走政策原文的矩阵。

上一轮我复核后说"两边一致、无需改动"是错的 —— 我只对比了政策文本与
SuitabilityService,漏了第三个东西:客服自己的知识库。POL-AST-012 就是第十二条矩阵
原文,而客服为 C1-C5 补的「能买什么产品」问答答案直接取自该矩阵(docs/24 第二节)。
于是同一个客服 Agent 对同一个问题给出相反答案:

  问"C1 能买什么产品"   → 知识检索答 R1、R2 可买(矩阵口径)
  问"C1 能买这只 R2 吗" → 适当性出口答不能购买(严格 C ≥ R)

政策冲突的精确位置也不是"矩阵 vs 硬匹配",而是第十四条**第 1 款与它自己的第 2、3 款**
矛盾:第 2 款说"低于一个等级以上"才拒绝(C1→R2 只低 1 级,够不上"以上"),第 3 款禁止
C1 买 R3+、C2 买 R4+(R2 不在禁止列表)。矩阵与第 2、3 款三方一致,孤立的是第 1 款。

改动:

- suitability_service.py 新增 MATRIX_ALLOWED / MATRIX_NEEDS_DISCLOSURE,_decide 由
  "C < R 即拒绝"改为按矩阵:C1→R2、C2→R3 直接可买;C3→R4、C4→R5 走第十五条豁免档
  (reason_code=SUITABLE_WITH_DISCLOSURE,强制揭示 + 确认 + 录音);低两级及以上仍拒绝。
- 客服话术分档:越级档不再说"在您的风险承受能力范围内" —— 那句只对 C ≥ R 成立,
  用在 C1 买 R2 上会让客户以为自己的测评本来就覆盖这只产品。
- 风控侧不动,保留 C ≥ R:它要发现的是"越级成交且留痕不全",这个差异是有意保留的。

测试:新增逐格对照政策原文的 25 格矩阵用例、豁免档用例、话术分档用例(+29)。
docs/25 第七节 #1 与 docs/24 第七节同步更正,包括写明我上一轮那个结论错在哪。

门禁:ruff 干净 / mypy 137 文件 / 693 unit+contract / 33 integration。
2026-09-11 14:47:08 +08:00

385 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 25 · 风控模块代码评审报告
> **评审对象**:合并 `origin/qyqy_develop`(`870fd0d`)带入的风控模块(约 5000 行)。
>
> **评审方式**:4 个并行评审(架构接入 / 数据层与数据库基线 / 业务逻辑正确性 / API 规范),
> 关键结论由本人逐条核对代码或实测数据库复核。
>
> **验证标记**:
> - ✅ **已验证**:本人核对过代码原文或实测过数据库,证据在文中;
> - 🔁 **交叉印证**:两位评审独立发现同一问题(可信度更高);
> - ⚠️ **待复核**:评审提出、本人未逐条核对。
>
> **本报告只陈述问题与依据,不含任何修改。** 修复请另行安排。
---
## 一、总体结论
**骨架是合规的**,这一点要先说清楚:风控正确继承了 `BaseAgent`、只实现 `handle()`、工具调用统一走 `self.call_tool`、ORM 与数据库基线**逐列吻合且没有改动任何已有表**、全仓无字符串拼 SQL、权限 scope 为空时默认拒绝、repository 严格只读。
问题集中在**三条断线**和**一批业务正确性缺陷**:
1. **运行期配置没发布** → 风控在当前环境**跑不起来**;
2. ~~**模型调用绕过基座的模型服务** → 治理链路断了一环;~~
**复核后更正:这是基座的能力缺口(缺 chat + tools 形态的入口),不是风控违规** —— 详见下文「2.」;
3. **时区口径不统一** → 定时规则判错时段、日报日界错位(两位评审独立发现)。
---
## 二、P0 · 阻断级
### 1. 意图配置与工具白名单一条都没发布 ✅
实测数据库:
```
platform_config_item(当前生效版本 5 条):customer_service:faq / policy_explain /
product_inquiry / suitability_check / fund_query_demo:fund_quote ← 无任何 risk:*
agent_intent_config 中 agent_type='risk':0 条
```
风控声明了 4 个意图(`risk_agent.py:19-22`:`risk_overview` / `risk_search` / `risk_evidence` / `general`)。
底座的白名单是**失败关闭**的:`ToolExecutor` 取「发布配置的 `allowed_tools`」与「代码声明的
`allowed_tools`」**交集**,缺配置时交集为空 ⇒ **任何 `call_tool` 都被拒绝**。
**表现**:风控只能走 `handle` 里不依赖工具的 fallback 分支,等于不可用。
**修复**:补 `agent_intent_config` 与发布版 `agent_tools` 的 `risk:<intent>`。
注意 `config_release` 是**整版本替换**语义,必须继承现有 5 条配置项,否则会把客服的白名单清空。
---
## 三、P1 · 安全与治理
### 2. 模型调用自建客户端(复核后定性:基座能力缺口,不是违规)✅
- 底座**有**公共入口:`base.py:79-84` 的 `generate_with_model()`,且 `base.py:86-95` 把它列入
**禁止子类覆写**的集合 —— 底座明确视其为治理方法。
- 风控却自己构造客户端:`risk_agent.py:133` `self._chat_model_client = model_client or RiskAgentModelClient()`,
`:225` 用它调模型;`risk_agent_model_client.py:88-96` 自己发 HTTP。
- `bootstrap.py:262-265` 的 `lambda _context: RiskAgent(RiskAgent.definition)` 让 `model_client`
**永远是默认值**,连注入替换都做不到。
它**复用了 gateway 的配置解析**(`DatabaseModelEndpointResolver` + `EnvironmentSecretResolver`),
但**没走 gateway 的调用层** —— 降级、端点排序、统一错误映射,以及 gateway 将来的任何改进
(限流、成本统计)它都享受不到。
其**修复建议原先写作"改走 `ModelGenerationService`,或直接用 `self.generate_with_model`"——
该建议不成立,以下为复核后的更正(2026-09-11)。**
**前提复核**:基座的公共入口**做不到风控需要的事**。
- `OpenAICompatibleGateway.generate(endpoint_code, prompt, timeout_ms) -> str`
(`model_gateway.py:57`)与 `ModelDispatchService.generate(endpoints, prompt) -> ModelExecution`
(`:248`)都是**「给一段 prompt、拿一段文本」**;`ModelExecution` 的字段只有
`(endpoint_code, text, attempts, degraded)`(`:226-231`)。
- 而风控需要的是 **messages 数组 + tools(function calling)+ 解析 tool_calls**,
基座完全没有这种形态的入口。
**所以这不是"绕过",是基座缺能力时的补位。** 而且它补得相当规矩:
| 它复刻的东西 | 与基座的一致性 |
|---|---|
| 端点选择 | 直接用基座的 `DatabaseModelEndpointResolver` + `EnvironmentSecretResolver` |
| 降级策略 | 与 `ModelDispatchService.generate` 一致:按声明顺序尝试、`max_attempts` 默认 2 |
| 错误映射 | 与基座同一套:`UpstreamTimeoutError` / `DependencyUnavailableError` |
**仍然成立的两点**(与本条前半无关,但值得修):
1. `bootstrap.py:262-265` 的 `lambda _context: RiskAgent(RiskAgent.definition)` 让 `model_client`
**永远是默认值**,注入点形同虚设 —— 无论将来走哪条路,这个 lambda 都该改。
2. `risk_agent_model_client.py` 里的 HTTP 调用逻辑与 gateway **重复了一份**:
gateway 将来加限流、成本统计、统一日志,风控享受不到。
**正确的修法**(比原建议大,且要动基座):给基座**新增** `chat(messages, tools)` 形态的入口
(`ModelGateway` Protocol → `OpenAICompatibleGateway` → `ModelDispatchService` →
`ModelGenerationService` → `BaseAgent`),再让风控改用它。因为**只是新增方法、不改现有行为**,
对已有 Agent 零影响,但涉及基座核心链路,**是否做需要项目方定**(本轮未实施)。
**业务裁定(2026-09-11):本轮不补,按基座能力缺口记录。**
理由:风控已经跑通,收益只在风控一侧;而 `chat(messages, tools)` 要贯穿
`ModelGateway` → `OpenAICompatibleGateway` → `ModelDispatchService` →
`ModelGenerationService` → `BaseAgent` **整条链路**,属于公共契约变更 —— 在演示与联调期间
改它,影响面大于收益。留待基座排期时一并做,届时风控改用它是替换
`RiskAgentModelClient` 一个类的事。
上面"仍然成立的两点"里,第 1 点(`bootstrap.py:262-265` 的 lambda 让注入点形同虚设)本轮
同样**不改**:它只在注入替身时才看得出差别,不影响任何行为,等基座补 `chat` 入口时一起处理。
第 2 点(HTTP 逻辑重复一份)随第 1 点一起消失。
### 3. 能力过滤失效,当前能工作只是巧合 🔁 ✅
`risk_agent_model_client.py:51-54` 传 `task_type="risk_agent_chat"`,不在 `TASK_CAPABILITY` 里 →
`model_gateway.py:196-198` **返回全部 active 端点** → 客户端只取前 `max_attempts`(默认 2)个。
**实测端点表**:`id=3 deepseek-flash`(caps 含 `text_generation`)恰好排在
`id=5 qwen-embedding` 前面,所以现在能拿到文本端点。
**但这是巧合**:`model_gateway.py:181-184` 的注释自己警告过"取决于端点在表里的顺序"是缺陷。
**修复**:`TASK_CAPABILITY` 补 `"risk_agent_chat": "text_generation"`(及相关 task_type)。
### 4. `POST /daily-report/mail` 无权限校验 ✅
- `api/controllers/risk.py:207-217`:只有 `build_request_context`(管 401),**无任何权限校验**。
- `risk_daily_report_mail_service.py:16-42`:无 `AuthorizationService`、拿不到 context,
**收件人 / 主题 / 正文全部由客户端决定**。
**当前不可被利用**:`:17-20` 有两层默认关闭开关 ——
`RISK_DAILY_REPORT_MAIL_ENABLED` 默认 `False`(返回 `disabled`)、`DRY_RUN` 默认 `True`。
**但一旦运维开启 SMTP,它就是一个未授权的邮件发送器。**
**修复**:在开启 SMTP 之前必须先加权限校验与收件人白名单。
### 5. Agent 在 `handle` 里直接写库 ✅
`risk_agent.py:141-146` 直接调 `RiskAnalysisService.generate_for_context(...)`;
`risk_analysis_service.py:145-156` 写 `alert.ai_analysis` 并 `commit()`。
**它不是"无审计的黑箱"** —— `:63` 有 `AuthorizationService.require`、`:147-155` 手写了 `InteractionAudit`。
真正的问题是两条:
1. 审计**不经基座统一链路**(只有走 `call_tool` 才有 `_tool_records` 与统一审计);
2. ~~`ai_analysis` 是**读-改-写且无并发控制**,并发请求会互相覆盖。~~
**本条经复核为误报,已撤回(2026-09-11)。** 证据:`risk_analysis_service.py:121-125`
的查询上**一直有** `.with_for_update()`,读-改-写(`:138-145`)整体在行锁内、到 `:156`
才提交。实测并发发起三种分析(预警研判 / 回访话术 / 工单摘要,各自独立 session、
复用同一 session 会天然串行测不出竞争),落库的 `ai_analysis` **三个键全部保留**,
未发生覆盖。
初次报告把它写成缺陷,原因是我只读到 `:130-160` 这一段、**恰好漏看了锁所在的
`:121-125`**,却把这一条标成了"✅ 已验证"。教训与前面 `_topic_of` 那几轮相同:
**标注"已验证"之前必须确认自己真的读到了关键那段代码**,否则标注本身就是误导。
### 6. 时区口径不统一 🔁 ✅(最严重的业务缺陷)
**定时规则判错时段**:`risk_scan_service.py:248` `0 <= confirmed_at.hour < 6`,
而 `:258` 生成的摘要写的是「**凌晨时段**发生 X 元交易」。库内是 UTC ⇒
`[0,6)` UTC = **北京时间 08:00–14:00**。**规则本意是"凌晨",实际判的是整个上午**,会持续误报。
同源代码见 `risk_judgement_service.py:238`,`:243` 还把 UTC 小时当北京时间展示。
**日报日界错位**:`risk_daily_report_service.py:89` 用 `generated_at.date()` 取日界,
而 `:61` 传入的是 `_utc_naive(now)`;`:119` 的 `report_date` 用 UTC 日期,`:415` 却按
Asia/Shanghai 展示。北京 08:00 前生成时,统计窗口是"前一日 08:00 – 当日 08:00",跨了零点;
`:241` 的"当日新增"又与展示时间矛盾。
**REST 与 Agent 两条路径口径不一致**:`api/schemas/risk.py:32-33` 的时间参数是**裸 `datetime`**,
`risk_query_service.py:77-78` 原样透传去比 UTC 列;而 Agent 路径做了时区转换
(`risk_natural_language.py:117` `tzinfo=SHANGHAI`)。
**修复**:统一约定"库里 UTC、边界处按北京时间换算",并在 REST 入口做同样的规范化。
---
## 四、P2 · 业务正确性(会导致误报 / 漏报)⚠️
| # | 问题 | 位置 | 后果 |
|---|---|---|---|
| 7 | 日报"误报原因"恒为"未填写" | `risk_repository.py:944-967` 的 `_alert_row` 不含 `close_reason`,`risk_daily_report_service.py:137` 读 `item.get("close_reason") or "未填写"` | 日报字段永远为空,失去分析价值 |
| 8 | 研判漏了阈值条件 | `risk_judgement_service.py:189` 只用 age/amount 判 RW-012;扫描侧 `risk_scan_service.py:209` 还有 `amount < Decimal(average) * 3` | 未达 3 倍均值也判"证据支持风险" ⇒ **误报** |
| 9 | 合并告警破坏证据结构 | `risk_scan_service.py:368` 重建 snapshot 只留 `product_id` + `merged_alerts`;`risk_judgement_service.py:103` 的 `snapshot.get("ratio")` 恒为 None | RW-003 研判降级 |
| 10 | 列表级无条件放行 | `risk_judgement_service.py:43-50`:`_assess_list_rule` 收到 `item` **却完全没用**,对 RW-018 硬编码返回「已解除」+ 硬编码理由 | 列表显示"已解除"、不会触发人工核查(详情级 `:259` 另有校验,点进去是对的) |
| 11 | 通知失败被吞 | `risk_scan_service.py:449` `except Exception: … return 0`,预警照常 commit,`:418` 只回 `notification_count=0` | **无法区分"无需通知"和"高风险通知创建失败"** —— 风控里这很危险 |
| 12 | 定时扫描重启后不再执行 | `risk_scan_scheduler.py:102` `last_run_at` 仅存内存;`:123` `if self.last_run_at is None: return config.run_immediately`(默认 False,`config.py:65`) | **重启后永不 due**;`:136` 失败后每 30s 无退避重试 |
| 13 | 一条脏数据中断整批 | `risk_scan_service.py:468` `int(level.replace(prefix, ""))` 遇非 C1-C5 抛 `ValueError`,`refresh_alerts` 无逐条隔离,`:411` 整批 rollback | 一条脏数据 ⇒ 整批扫描失败 |
| 14 | 年龄口径不一 | `risk_scan_service.py:462` 用 UTC 日期,`risk_judgement_service.py:381` 用服务器 `date.today()` | 生日边界差 1 天,65 岁阈值可能翻面 |
| 15 | 幂等仅在应用层 | `risk_scan_service.py:332` `_exists` + JSON 列无唯一索引;`:37` 的 asyncio.Lock 仅进程内 | 多 Web worker 并发存在重复告警窗口 |
| 16 | ~~状态跳变与静默截断~~ **复核后拆成三部分,见下方说明** | `risk_action_service.py:65-66`;`:103` | ~~处置流程可被绕过;评分被静默改写~~ |
**第 16 条的复核结论(2026-09-11)**:原描述把三件事混成了一条,其中两件不成立、一件是真缺陷。
**a. `exclude` 不要求"调查中" —— 这是业务规则,不是技术缺陷。**
`exclude`(`:63-66`)要求 `_require_acknowledged` + `_require_open`,而 `resolve`(`:90`)
额外要求 `alert.status == "调查中"`。两者不对称是事实,但**误报关闭是否必须先经调查,是业务
规则**:把明显误报(如系统重复触发)也强制走一遍调查,未必是想要的效果。代码里两道门是有意
设置的,看不出实现偏差。**需要业务方裁定**,不由技术侧单方面加门禁。
**业务裁定(2026-09-11):保持现状,不加门禁。** `exclude` 继续只要求"已确认接收 +
未闭环"。理由与复核时的判断一致:强制"调查中"会让明显误报(如系统重复触发)也必须走
一遍调查流程,代价大于收益;`exclude` 仍会写审计、仍要求已确认接收,不是无声关闭。
**b. `min(20, score_before)` —— 误报。**
`BEHAVIOR_SCORE_INITIAL = 20` 是**满分**(`:18`),扣分表 `{"低":3, "中":5, "高":20}`(`:19`)
与之自洽(高危扣满、归零)。所以 `min(20, ...)` 是**把越界数据拉回合法上限**,属于数据清洗。
而且它**不是静默的**:审计里同时记了 `behavior_score_before` / `behavior_score_deduction` /
`behavior_score_after`(`:118-120`)。原描述说"静默改写"不成立。
**c. 真缺陷(原报告没写):`behavior_score` 初始值是 0,不是满分 20,导致扣分机制整体失效。**
实测:`fin_customer_profile.behavior_score` 定义为 `int NOT NULL`(**无默认值**),
现有画像 `customer_id=9001` 的值是 **0**。
于是 `:103` 的计算恒为:`min(20, 0) - deduction = -deduction` → `max(0, ...) = 0` ——
**扣分永远扣不动,行为分恒为 0**。而且全仓只有 `risk_action_service.py:109` 一处给
`behavior_score` 赋值(写入方只有风控结案),说明**初始值来自插入画像时的显式 0**,
没有任何地方把它初始化成 20。
影响:行为分是"预警结案 → 客户行为评分下降"这条链路的落点,现在这条链路**产出为零**。
**补充复核(同日):这不是本项目的代码缺陷,而是上游数据前提缺失。**
全仓搜索后确认,**本项目不创建 `fin_customer_profile` 行**:`FundCustomerProfile(` 只出现在
类定义与测试里,没有任何 INSERT;`profile_assembly_service` 走的是 ORM 读取 + 属性更新,
行不存在时返回 `profile_row_not_opened`。也就是说画像行由**本项目之外的流程**写入,
而它写入时把 `behavior_score` 设成了 0。
本项目的扣分逻辑本身是对的:`min(满分, before) - deduction` 再 `max(0, ...)` —— 给定 0,
任何扣分都只能得 0,这是算术必然,不是判断错误。
**处置建议**(不由技术侧单方面决定):
1. 上游创建画像时按满分初始化 `behavior_score`(需上游确认该字段的语义与取值范围);
2. 若上游无法改,则需要一个**能区分"未初始化"与"扣光了"**的标记(例如新增一列),
本项目才能在重建画像时补初值 —— 但仅为了这个目的加列,成本收益需要权衡。
在拿到上游口径之前,本项目**不做**任何"见 0 就补 20"的处理:0 同样是合法的扣分结果,
那样会把真正扣到 0 的客户错误地抬回满分。
---
## 五、P3 · 接口规范与性能 ⚠️
| # | 问题 | 位置 |
|---|---|---|
| 17 | 列表分页元数据放错层级:`next_cursor`/`has_more` 塞进 `data`,而 `docs/05` §3.3 要求放 `meta` 且明令"不得增加其他顶层字段"(`_envelope` 只输出 `trace_id`,已 ✅ 核对 `api/controllers/risk.py:220-224`) | `risk_query_service.py:158-167`,影响 5 个列表端点 |
| 18 | 游标只存 offset、**未绑定用户与查询条件**(`docs/05` §16.1 要求绑定),且 offset 无上界 | `risk_cursor.py:15` |
| 19 | offset 游标 + 可变排序键 ⇒ 并发下必然跳行或重复(排序首列是 `case(alert_level==HIGH…)`) | `risk_repository.py:155-166` |
| 20 | 无 `.limit()` 的全量查询:三处 select 无 limit(登录记录连时间窗都没有);`load()` 取全部未闭环告警(注释自认"不应用分页") | `risk_repository.py:251-274`、`306-316` |
| 21 | 索引失效:`trigger_rule_codes.contains([...])` → `JSON_CONTAINS`,基线 `fin_risk_alert` 无该列索引;多处 `like(f"%{keyword}%")` 全表扫 | `risk_repository.py:724`、`390`、`705-713` |
| 22 | 写操作无幂等:6 个 POST 端点无 `Idempotency-Key`(`docs/05` §5.1/§6.2) | `api/controllers/risk.py:60、89、100、111、167、207` |
| 23 | 错误码超表:把 `AGENT_INPUT_INVALID`(表定 422)配了 **413**,而 413 不在 `docs/05` §3.5 状态码表内 | `risk_evidence_archive_service.py:51-54` |
| 24 | SSE 未校验 `Accept`(`SseNotAcceptableError`/406 已定义但未被使用) | `api/controllers/risk.py:182-204` |
| 25 | 接口未登记 `docs/05`:§19 目录里一条风控接口都没有,而 §20 明确要求"新增接口必须同步更新 §19";且 §12 约定的前缀是 `/risk-scans/**`、`/risk-alerts/**`,实现是 `/api/v1/risk` | `docs/05-接口文档.md` §12/§19/§20 |
**P3 处理结果(2026-09-11)**
| # | 状态 | 处理 |
|---|---|---|
| 17 | ✅ 已修 | 列表信封改为 `{data: [...], meta: {trace_id, next_cursor, has_more}}`,对齐 §3.3;五个列表端点统一走 `_list_envelope` |
| 18 | ✅ 已修 | 游标内嵌 SHA-256 指纹(`user_id` + `data_scope`/`customer_ids` + 查询条件;`/evidence/{source}` 的 `source` 一并绑定,否则 customers 的游标能直接翻 products)。指纹不符一律 `400 INVALID_CURSOR`。**刻意排除 `limit`**:它是分页参数不是查询条件 |
| 19 | ⚠️ 已知限制 | offset 游标无法根治跳行/重复,要根治得改 keyset 分页(游标携带"上一页最后一条的排序键")。这会**改动分页协议本身**,且当前排序首列是 `case(alert_level=HIGH…)` 这种计算列,需要连排序一起去掉 —— 属于协议级重做,不在本轮单方面改 |
| 20 | ✅ 已修 | 详情证据每类封顶 200 条、日报每组封顶 5000 条,用 `limit + 1` 判定截断,并在响应里暴露 `evidence_truncated` / `data_truncated`。**不静默截断**:日报计数直接来自行数,静默截断等于给出一份看起来正常、实际少统计的日报 |
| 21 | 🟡 部分修复 | `trigger_rule_codes` 已加 JSON 多值索引(迁移 `20260911_risk_rule_index`);`EXPLAIN` 由 `type=ALL`、`possible_keys=NULL` 变为 `type=range` 并命中 `idx_fin_risk_alert_trigger_rule_codes`,实测证据留档在 `docs/evidence/risk-index-probe.json`。**`like(f"%{keyword}%")` 依然全表扫**:前后通配符在 B-tree 上无解,根治需全文索引 + 中文分词组件(部署依赖),本轮不做,如实记为限制 |
| 22 | ✅ 已修 | 6 个写接口接入平台 `api_request_receipt` 幂等。新增 `ApiTransactionService.execute_in`:原 `execute` 自开 `SessionFactory()` 与 `session.begin()`,而 `RiskActionService._finish` 内部会 commit,套进去就是"内层提交外层事务",故改为在调用方事务内读写幂等记录 |
| 23 | ✅ 已修 | **不把 413 降成 422**:413 是上传超限的标准语义,前端文档也已按 413 做提示映射,改为在 `docs/05` §3.5 状态码表**补登** 413,契约以"补齐"而非"改动"方式对齐 |
| 24 | ✅ 已修 | SSE 端点补 `Accept` 协商(抽到 `app/api/dependencies/negotiation.py` 与 `/agent-runs/{run_id}/events` 共用)。顺带发现一个更隐蔽的问题:鉴权原本在 async generator 内部,403 只能在响应头发出**之后**抛出,表现为"200 + 半截流",现改为构造 `StreamingResponse` 前完成 |
| 25 | 🟡 部分成立 | "§19 一条风控接口都没有"**不成立**:§19 末尾写明业务域接口由各自业务文档登记,15 条端点已在 `docs/风控业务演示文档/06-模块接口与字段映射.md` 逐条登记。真问题是 §12 写的 `/risk-scans/**`、`/risk-alerts/**` 与实际实现 `/api/v1/risk/**` 不符,已按实现更新 §12 并加说明 |
---
## 六、做得好、建议保持 ✅
- **ORM 与数据库基线逐列吻合,没有改动任何已有表** —— 你们那条"不可变基线"的红线守住了;
`alembic/env.py` 的 `target_metadata = None` 也保证了 ORM 不会反向改表。
- **全仓无字符串拼 SQL**,纯 SQLAlchemy 表达式;`f"%{keyword}%"` 只是绑定参数的值。
- **默认拒绝**:权限 scope 为 None/denied 时返回 `false()` 而不是放行。
- **repository 严格只读**:模块内无 `add/update/delete/commit`。
- **三个工具 `read_only` 默认 True**,没碰"Agent 公共工具仅允许只读"这条红线。
- **输出防护**:Agent 侧拦截越权处置话术与协议标记、内部 ID 脱敏、体积限制;
API 侧不透出审计信息、预警详情刻意规避内部主键。
- **上传有大小与类型双重校验**:10MB 上限、魔数/zip 结构/UTF-8 校验、路径限定在项目内、防覆盖。
- **状态机守卫扎实**:`with_for_update`、防重复确认/升级、已关闭不可更新。
- **扫描单事务**:异常 rollback 后 raise,不是静默成功。
- **双层并发保护**:asyncio.Lock + MySQL `GET_LOCK`。
- **schema 严格**:请求模型普遍 `extra="forbid", frozen=True`,长度与范围校验齐全。
---
## 七、需要业务方裁定的问题
### 1. 政策文档自相矛盾 ✅(本人已核对原文)
- `knowledge/policy/个人投资者适当性管理指南.md:306` 第十二条匹配矩阵:**C1 可购买 R2**;
- 同文件 `:330` 第十四条:"**正向匹配**:投资者风险等级必须**大于或等于**产品风险等级,方可购买"。
C1(1) 与 R2(2) 相比 `1 < 2`:**按矩阵可以买,按第十四条不能买**。
风控扫描按后者实现(`risk_scan_service.py:169` `gap > 0 and missing_trace`)。
**这不是代码问题,是制度文本冲突**,需要业务方定一条为准。客服侧的适当性裁决走的是
`check_suitability`(按档案等级与匹配规则),两边口径也需要对齐。
**业务裁定(2026-09-11,经一次自我更正):客服按第十二条矩阵,风控保留 `C ≥ R`。**
第一次复核我只对比了政策文本与 `SuitabilityService`,得出"两边一致、无需改动" ——
**那个结论是错的**,因为漏了第三个东西:**客服自己的知识库**。
- 知识库 `POL-AST-012` 就是第十二条矩阵原文(`个人投资者适当性管理指南.md:302-310`),
而客服为 C1–C5 各补的「能买什么产品」问答,答案**直接取自该矩阵**(`docs/24` 第二节);
- 而 `SuitabilityService._decide` 用的是严格 `C < R 即拒绝`(第十四条**第 1 款**)。
于是**同一个客服 Agent 对同一个问题给出相反答案**:问"C1 能买什么产品"答"R1、R2 可买",
问"C1 能买这只 R2 吗"答"不能购买"。这不是"两侧是否需要对齐",而是客服**内部**自相矛盾,
且客户可见。
**冲突的精确位置**也不是原报告写的"矩阵 vs 硬匹配",而是第十四条**第 1 款与它自己的
第 2、3 款**之间:
| 条款 | 原文要点 | 对 C1→R2 的结论 |
|---|---|---|
| 第十四条 1 | 投资者等级**大于或等于**产品等级 | 拒绝(1 < 2) |
| 第十四条 2 | 低于**一个等级以上**的才拒绝 | 允许(只低 1 级,够不上"以上") |
| 第十四条 3 | C1 不得买 R3+、C2 不得买 R4+ | 允许(R2 不在禁止列表里) |
| 第十二条矩阵 | C1 行的 R1/R2 标 ✅ 可购买 | 允许 |
第 2、3 款与矩阵三方一致,孤立的是第 1 款。
**落实**:客服侧改为按矩阵裁决 —— `suitability_service.py` 新增 `MATRIX_ALLOWED` 与
`MATRIX_NEEDS_DISCLOSURE`,`C3→R4`、`C4→R5` 走豁免档(`SUITABLE_WITH_DISCLOSURE`,
强制揭示 + 确认 + 录音),并补了**逐格对照政策原文的 25 格用例**
(`tests/unit/service/test_suitability_service.py`)。客服话术也随之分档:越级档不再说
"在您的风险承受能力范围内"(那会让客户以为自己的测评本来就覆盖这只产品)。
**风控保留 `C ≥ R`**:它要发现的是"越级成交且留痕不全",用更严的口径事后核查。
两侧职责不同,这个差异是有意保留的。
**仍然成立的一点**:专业投资者上,客服侧豁免等级匹配但强制揭示/确认/录音
(`suitability_service.py`),风控扫描不做等级豁免而是直接查留痕。两者合起来是同一句话 ——
豁免等级不等于豁免留痕。
### 2. 第十五条豁免规则未落地 ✅(业务裁定:实现,2026-09-11 已完成)
C3→R4(单只 ≤ 总资产 20%)、C4→R5(≤ 10%)的**持仓占比校验原先完全没有实现**;
`risk_judgement_service.py` 只要留痕齐全就判"疑似误报" —— 把豁免的**前提条件**当成了
结论。业务方裁定实现,两侧一起改:
- **扫描侧**:新增 `EXEMPTION_LIMITS` 与 `RiskRuleEngine._exemption_state`,核算
"单只持仓 / 总资产"并写进证据快照(`exemption_limit`、`exemption_ratio`、
`exemption_data_missing`、`holding_value`、`total_asset`);触发条件由
`gap > 0 and missing_trace` 改为 `gap > 0 and (missing_trace or 超出额度)`。
- **研判侧**:`_assess_rw007` 先判额度、再判留痕 —— 超限 → "证据支持风险";
留痕齐全且在额度内 → "疑似误报";留痕齐全但快照缺总资产/持仓 → "继续复核"。
**数据前提**(探查脚本 `tools/probe_exemption_data.py`,证据留档
`docs/evidence/exemption-data-probe.json`):实测库内 `fin_customer_profile` 只有 1 行、
`total_asset = 0.00`,`fin_holding` 为 0 行,且没有任何 `申购` 交易 —— 这条规则**当前不会
被触发**,与 `behavior_score` 同源:画像与持仓由本项目之外的流程写入。
因此刻意**不**把"算不出来"当成"超限"。拿 `total_asset = 0` 去算,每一笔 C3→R4 都会变成
违规,豁免规则反而成了新的误报源。数据缺失时扫描侧不产生预警,研判侧返回"继续复核"并
要求补查总资产与持仓快照 —— 由人工定案,而不是用缺失数据假装有结论。
上游把总资产与持仓写入之后,这条链路**无需再改代码**即可生效。
### 3. `docs/24` 需要同步更新
`docs/24-客服Agent阶段性总结与下阶段计划.md` 里的「风控 Agent:需要先有规则引擎,尚未启动」
与本报告结论已不符;"当前状态"表的数字也已被本次合并刷新。
---
## 八、建议的修复顺序
1. **发配置**(P0 #1)—— 这是风控能不能跑的前提;务必继承现有 5 条配置项。
2. **时区统一**(P1 #6)—— 修完误报会明显下降,这也是"北京时间"要求的落地。
3. **模型走 gateway + 补 capability**(P1 #2 #3)—— 消除对端点表行顺序的隐式依赖。
4. **邮件端点加权限**(P1 #4)—— 必须在开启 SMTP 之前。
5. **通知失败不再静默**(P2 #11)、**扫描调度持久化**(P2 #12)—— 这两条直接关系到
"风控会不会悄悄不工作",属于风控系统的基本可信度。
6. **政策口径裁定**(七 #1)—— 需要业务方拍板,然后代码与知识库一起对齐。
7. 其余 P2/P3 按需排期。