你说得对: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。
385 lines
28 KiB
Markdown
385 lines
28 KiB
Markdown
# 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 按需排期。
|