Files
group_fqcd_jr/docs/25-风控模块代码评审报告.md
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

28 KiB
Raw Permalink Blame History

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 按需排期。