Files
group_xinghuo_jinrong/docs/项目框架设计/改进方案评审-问题清单与对比.md
T

489 lines
30 KiB
Markdown
Raw Normal View History

# XingHuo 架构改进方案评审 · 问题清单与方案对比
> **文档性质**:送审材料。供另一个 AI 在全新上下文中独立审核。
> **代码基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`(2026-09-08),全量 pytest 503 绿。
> **本次改动清单:无(零代码改动)。** 本轮仅产出评审材料,未修改任何业务代码。
> **配套文档**:《docs/项目框架设计/架构设计说明书.md》(本次评审的事实来源,§7.3 与 §9.4 为本清单的初始输入)。
## 审核记录
### 第一轮(2026-09-09,独立 AI 全新上下文审核)
**总体结论:需修改后实施(小改)。** 六项核心事实断言(A1 / A2 / B1 / B2 / C1 / F1)**全部属实**,证据确凿、推导正确,**无阻断级事实错误**。红线相关实现(审计只 INSERT、R-02 唯一阻断路径、Core 只读)在代码中均有落点,未发现颠覆性安全或正确性遗漏。
审核提出 3 项重要修正 + 2 项可选修正,**本轮已全部吸收**,修订点如下:
| # | 审核意见 | 档位 | 本轮处理 | 位置 |
| --- | --- | --- | --- | --- |
| 1 | 只加 UNIQUE 不加重试,并发会从静默脏数据变成 500 硬失败 | 重要 | 已补充实施配套要求 | §3 B1 |
| 2 | §5 缺"生产部署拓扑(单进程 vs 多实例)"决策项 | 重要 | 已新增待确认第 7 项 | §5 |
| 3 | 放开 convert 后,聚合层两处 SQL 过滤会让它"隐形" | 重要 | 已补充并标为易漏点 | §3 F1 |
| 4 | `next_seq_no` + `insert_message` 是更危险的并行取号路径 | 可选 | 已补充警示 | §3 B1 |
| 5 | C1 严重度略夸大(方法全为固定 SELECT,非纯约定) | 可选 | 已校正表述 | §3 C1 |
**未吸收意见**:无。
---
## 0. 结论速览
| 状态 | 数量 | 说明 |
| --- | --- | --- |
| 未修复 · 建议修 | 9 | 正确性隐患与合规风险,见 §3 B/C 类 |
| 未修复 · 文档口径错误 | 5 | 改文档即可,零代码风险,见 §3 A 类 |
| 待确认 · 需人工拍板 | 4 | 涉及接口协议或架构选型,见 §5 |
| 非缺陷 · 仅需文档标注 | 3 | 设计正确但易被误读,见 §3 G 类 |
| **合计** | **21** | |
**三条最需要优先处理的**(按"后果严重性 × 触发概率"排序):
1. **B1** `agent_message` 的 `(session_id, seq_no)` 是普通索引而非唯一索引 → 并发同会话会**静默产生重号消息**(本轮新发现,此前未记录)
2. **C1** `core_ro` 只读纯靠约定,无 DB 级账号保护 → 项目五条红线之一是"Core C1~C5 不可被覆盖",当前无任何强制手段
3. **B2** 中间件执行顺序无测试守卫 → 调换两个装饰器会**静默丢失全站 trace_id**,且不报错
---
## 1. 本次改动清单
**无。**
原因:本轮会话(2026-09-09)全程为架构讲解与术语答疑,**未形成任何经确认的改动方案**。用户于本轮末尾选定"先出方案对比文档,不改码",故本轮仅产出本材料,等待拍板后再实施。
| 文件 | 改动点 | 原因 |
| --- | --- | --- |
| — | — | 本轮零代码改动 |
---
## 2. 问题汇总表
| ID | 问题 | 类别 | 状态 | 影响范围 | 优先级 |
| --- | --- | --- | --- | --- | --- |
| A1 | 注入词表文档写 42 条,实测 45 条 | 文档口径 | 未修复 | `MEMORY.md` §0 | 低 |
| A2 | 风控对话 Tool 文档写 4 个,实测 5 个 | 文档口径 | 未修复 | `MEMORY.md` 仓库地图 | 低 |
| A3 | Agent 专用表 SQL 注释写 5 张,实建 6 张 | 文档口径 | 未修复 | `02-mysql-agent专用.sql` | 低 |
| A4 | `scoring.py` 实为 `NotImplementedError` 桩,文档未标明 | 文档口径 | 未修复 | L3 `risk_score` 恒 NULL | 中 |
| A5 | `locks.py` 实为进程内锁,文档未标明 | 文档口径 | 未修复 | 多实例部署 | 中 |
| B1 | `agent_message` 的 seq 索引非唯一,并发会静默重号 | 正确性 | 未修复 | 消息顺序 / LLM 上下文 | **高** |
| B2 | 中间件执行顺序无测试守卫 | 正确性 | 未修复 | 全站 trace_id | **高** |
| B3 | `deny()` 双写无事务,可能只写一半 | 正确性 | 未修复 | 安全台账完整性 | 中 |
| B4 | `record_trade_alerts` 出单双写无事务 | 正确性 | 未修复 | 审计完整性 | 中 |
| B5 | 限流 `INCR` + 首命中 `EXPIRE` 非原子 | 正确性 | 未修复 | 限流窗口 | 中 |
| B6 | `locks.py` 进程内锁,多实例失效 | 正确性 | 未修复 | 预警聚合 / L3 | 中 |
| C1 | `core_ro` 无 DB 级只读账号 | 安全合规 | 未修复 | Core 红线 | **高** |
| C2 | dev debug 通道误配即放开无签名身份 | 安全合规 | 未修复 | 生产环境 | **高** |
| C3 | 审计 / 限流 fail-open 策略不可配置 | 安全合规 | 未修复 | 合规强场景 | 中 |
| C4 | 无 DeepSeek key 时静默降级,无告警 | 安全合规 | 未修复 | 生产可观测性 | 中 |
| D1 | `utils/logger.py` 仅一行 docstring,日志无落盘 | 可观测性 | 未修复 | 全站排障能力 | 中 |
| E1 | 权限逻辑分散在矩阵 / chat / tool 三处 | 可维护性 | 未修复 | 改权限需三处同步 | 中 |
| E2 | Tool 注册表分裂为三套 | 可维护性 | 未修复 | 新增 Tool | 低 |
| E3 | `model/` 层闲置,跨层全用 dict | 可维护性 | 未修复 | 类型安全 | 中 |
| E4 | 注入词表硬编码 45 条,扩展需改代码 | 可维护性 | 未修复 | 对抗性 prompt | 低 |
| F1 | 不支持 `convert` 交易类型(DB 支持,网关拒绝) | 能力 | 待确认 | 接口协议 | 待确认 |
| F2 | 意图识别为关键词匹配,换说法即漏触 | 能力 | 待确认 | 对话命中率 | 待确认 |
| F3 | R-05 动态评分未实现 | 能力 | 未修复 | L3 评分 | 低 |
| G1 | `trace_id` 可前端伪造 | 非缺陷 | 设计如此 | 需文档标注 | — |
| G2 | `trace_id` 概率唯一,无冲突检测 | 非缺陷 | 设计如此 | 需文档标注 | — |
| G3 | `admin.py` / `knowledge.py` 空壳 | 非缺陷 | 一期范围 | 需文档标注 | — |
> 说明:F1~F3 属能力增强而非缺陷,是否做取决于产品排期;G 类为"看起来像问题但设计正确",只需在文档中写明,避免后续被重复提出。
---
## 3. 逐条方案对比
### A 类 · 文档口径错误(5 项,改文档即可)
**A1** `input_guard.py:43-94` 实测 45 条(指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7),`MEMORY.md` §0 写 42 条。
- 方案 A:改 `MEMORY.md` 为 45 条 —— 零风险,10 秒完成
- 方案 B:不改 —— 后续读者按 42 条核对会误判
- **建议**:A。同源问题 A2/A3 一并改。
**A2** `chat_tools.py:328` `RISK_TOOL_REGISTRY` 实际注册 5 个(`query_overdue_alerts` / `alert_query` / `customer_context` / `suitability_check` / `aml_lookup`),另有 `query_agent_behavior:276` 定义但未注册。文档写"四个"。
**A3** `02-mysql-agent专用.sql` 注释写 5 张,实建 6 张(多出 `risk_aml_list`)。与 `entities.py` 注释"jinrong_agent 17 张"一致(11+6)。
**A4** `service/risk/scoring.py:18` `recompute_customer_score` 直接 `raise NotImplementedError`,是签名冻结的预留桩,**不是实际评分器**。L3 的 `risk_score` 一期恒 NULL。文档应明示,否则读者会以为评分已生效。
**A5** `service/risk/locks.py:31` 用 `threading.Lock`,是**进程内**锁,注释自陈"多进程部署换 Redis SET NX,接口不变"——**是 TODO,非已完成**。
---
### B 类 · 正确性隐患
#### B1 `agent_message` 并发会静默产生重号消息(本轮新发现)
**证据链**:
1. `session_repository.py:241-252`:`insert_turn` 在事务内取 `MAX(seq_no)+1`
2. 该事务的 SELECT 是**非锁定读**,MySQL InnoDB 默认 REPEATABLE READ 下两个并发事务仍可读到相同的 `MAX` 值
3. `01-mysql-共用底座.sql` 中 `agent_message` 只有 `KEY idx_session_seq (session_id, seq_no)` —— **普通索引,非 UNIQUE**
4. 代码注释自陈:"并发同会话不重号(**并发写锁归后续**)"——承认未做完
**后果**:两个事务算出同一个 `seq`,各自 INSERT 成功,**产生两条 seq_no 相同的消息**。`list_messages` 按 `seq_no` 排序时,同号两条的相对顺序由物理存储决定 → LLM 读到的上下文可能乱序("AI 先答、用户后问")。
**触发条件**:同一会话并发请求——多端同时发问、前端重复提交、SSE 断连重试。单用户串行对话不会触发。
| 方案 | 做法 | 收益 | 代价 |
| --- | --- | --- | --- |
| **A(推荐)** | 给 `(session_id, seq_no)` 加 UNIQUE 索引 | 重号从"静默脏数据"变为"数据库报错",可被 `insert_turn` 捕获后重试;同时提升查询性能 | 需先核查历史数据有无重号,有则先清洗;DDL 变更需确认 |
| B | 会话级 Redis 锁串行化 | 彻底避免并发 | 引入新依赖点,Redis 挂时退化;需设计锁粒度与超时 |
| C | 改用 `AUTO_INCREMENT` 的 `id` 排序 | 零冲突 | 改变现有 seq 语义,影响 `list_messages_page` 分页与前端 |
**建议**:优先 A。它是唯一能让"问题显式暴露"的方案——B/C 只是绕开,A 是把隐性错误变成显性失败,符合项目"宁多勿漏"的一贯口径。
> **⚠️ 实施 A 的必要配套(第一轮审核补充)**
> 当前 `insert_turn` **没有任何 `IntegrityError` 捕获与重试逻辑**。若只加 UNIQUE 索引而不加重试,并发同会话请求会从"静默脏数据"直接变成 **500 硬失败**——问题同样没解决,只是换了个表现形式。
> 实施时必须同步补:捕获 `IntegrityError` → 重读 `MAX(seq_no)` → 重试(建议 3 次,与 L3 乐观锁的重试次数保持一致)。
> **⚠️ 另有一条更危险的并行取号路径(第一轮审核补充)**
> `session_repository.py:134` `next_seq_no()` 用**非事务**的 `.connect()` 取号,`:142` `insert_message()` 单独 INSERT——**取号与写入分离,连事务都没有**,竞态比 `insert_turn` 更严重(连"同事务"这层缓解都没有)。
> `insert_turn` 是后来为修方案 C 评审 P0/P1 才新增的原子路径,旧的组合方式仍留在代码里。建议统一走 `insert_turn`,废弃 `next_seq_no + insert_message` 组合,或至少在方法 docstring 加警示。
#### B2 中间件执行顺序无测试守卫
**现状**:`main.py` 中 audit 先注册(81 行)、trace 后注册(87 行)。Starlette `add_middleware` 用 `insert(0)` 且 `build_middleware_stack` 反向包裹 → **数组越靠前越外层 → trace 在外层先执行**,audit 在内层,与其注释"执行序在 trace 之内"吻合。
**风险**:调换两个装饰器位置后,audit 会在 trace 之前执行,`current_trace()` 返回空串,**全站审计静默丢失 trace_id**,且不报错、不失败。
| 方案 | 做法 | 收益 | 代价 |
| --- | --- | --- | --- |
| **A(推荐)** | 加断言测试:发起请求后校验 `audit_log.trace_id` 非空 | 零业务代码改动,回归即发现 | 需访问数据库断言,或 mock 仓储捕获入参 |
| B | 在 `audit_middleware` 入口显式 `ensure_trace()` | 兜底生成,不依赖顺序 | 掩盖顺序错误,trace_id 与响应头不一致 |
**建议**:A。B 会让"顺序错了"这件事从可见故障变成隐性不一致,反而更难查。
#### B3 / B4 两处双写无事务
- **B3** `utils/authz.py:44-76`:两次 insert 在同一 `try` 内但**无事务** → 可能 `audit_log` 成功、`input_guard_log` 失败
- **B4** `alert_service.py:174-176`:`insert_alert` 与 `_audit` 两次独立 INSERT,**无事务** → 可能出单成功、审计丢失
**对照**:`handle_alert_with_audit`(`risk_repository.py:331`)**是同事务的**,注释明确"审计失败整体回滚,不产生无痕状态变更"。
| 方案 | 做法 | 收益 | 代价 |
| --- | --- | --- | --- |
| A | 三处统一改同事务 | 语义一致,审计完整 | 审计故障会连带回滚业务单据,与当前 fail-open 口径冲突,需先定 C3 |
| B | 维持现状,但加"审计写入失败"计数指标与告警 | 保留可用性优先,同时让丢失可见 | 需引入指标设施(当前无) |
| **C(建议)** | 维持现状 + 在文档中固化判据:"新增可丢留痕,变更不可丢" | 零改动,消除后续争议 | 无 |
**建议**:C 为当前最优。B3/B4 都是"新增"场景,最坏少一条留痕,业务单据仍在,与 §3 中"处置双写必须同事务"形成清晰判据。**但这条判据目前只存在于代码注释里,应写进文档**。
#### B5 限流 `INCR` + 首命中 `EXPIRE` 非原子
`input_guard.py` 的 `check_rate_limit`:首次命中时先 `INCR` 再 `EXPIRE`,两步之间进程崩溃会留下**无 TTL 的计数键** → 该 actor 被永久限流。
| 方案 | 做法 | 代价 |
| --- | --- | --- |
| A | 改用 Lua 脚本或 `SET key 1 EX 60 NX` + `INCR` 组合 | 需改 Redis 调用方式 |
| B | 用 Redis 的 `EXPIRE` 幂等重试 / 每次 INCR 后无条件 EXPIRE | 极小改动:每次都发 EXPIRE,不做"首命中"判断 |
**建议**:B,改动一行级别,可接受。
#### B6 进程内锁多实例失效
`locks.py:31` 用 `threading.Lock`,key 为 `agg:event:{customer_id}:{date}` / `l3:{customer_id}`。多进程/多实例部署时,两个实例各自持锁,**锁形同虚设**。
**缓解现状**:L3 更新另有乐观锁(`computed_at` 比对 + 3 次重试)兜底,跨进程天然有效,所以最坏是"多出一张重复单"而非数据错乱。
| 方案 | 做法 | 代价 |
| --- | --- | --- |
| A | 换 Redis `SET key val NX EX ttl`,接口 `run_locked` 不变 | 引入对 Redis 的强依赖,Redis 挂时退化为无锁(当前已 fail-open) |
| B | 维持现状,文档标注"单进程部署前提" | 零成本,但多实例时失效 |
**建议**:B(标注前提)+ 待多实例部署时再做 A。注释已预留接口,切换成本可控。
---
### C 类 · 安全与合规
#### C1 `core_ro` 无 DB 级只读账号(红线相关)
项目五条红线之一:"Core 正式 C1~C5 不可被画像覆盖"。当前保障手段仅为:双库物理隔离 + `core_ro.py` 类注释"仅 SELECT"。
**实际风险**:任何持有 `mysql_core_database` engine 的代码都能执行 INSERT/UPDATE,**无 SQL 层拦截、无 DB 只读账号**。唯一允许写 Core 的 `gateway_repository.insert_trade` 与 `core_ro` 相比只是"另一个类",无任何机制性区分。
> **审核校正(第一轮)**:`core_ro` 的方法**全部是硬编码的固定 SELECT**,要破坏它需要主动新增写方法,因此比"纯君子协定"略强。但本质上仍是"靠人不犯错",而非机制保障。**严重度维持高**,理由表述以本段为准——不夸大,也不因"暂时没出事"而降级。
| 方案 | 做法 | 收益 | 代价 |
| --- | --- | --- | --- |
| **A(推荐)** | 为 `core_ro` 配置独立的 MySQL 只读用户(GRANT SELECT ONLY) | 从根基卡死,代码怎么写都写不进去 | 需运维配合;`gateway_repository` 必须用另一个有写权限的账号,连接串要拆分 |
| B | 代码层加 SQL 白名单校验(只允许 SELECT 开头) | 不依赖运维 | 易被绕过(注释、子查询、存储过程);有性能开销 |
| C | 维持现状 | 零成本 | 红线无强制保障 |
**建议**:A。这是唯一"不可逆"的保障方式——B 是软约束,C 无保障。注意 A 需要拆连接串:`get_engine(settings.mysql_core_database)` 目前只有一个账号,读写共用。
#### C2 dev debug 通道误配风险
`deps.py:258`:仅当 `app_env == "development"` **且** `jwt_public_key_path` 为空时才走 `X-Debug-Role` / `X-Debug-Actor` 兜底。
**风险**:生产环境若误配 `app_env=development` 且未设公钥 → 任何人都能用 `X-Debug-Role: risk_officer` 头伪造身份,且**无签名**。
**现有缓解**:`main.py:49-57` 的 lifespan 在非 development 环境强制校验 JWT 就绪,且 `AUTH_FACTORY_IS_DEBUG` 双保险。
| 方案 | 做法 | 代价 |
| --- | --- | --- |
| A | 维持现状,在 `.env.example` 与部署文档加粗警示 | 零成本 |
| B | 启动时若 `app_env=development` 打 WARNING(已有类似:dev secret 默认值告警) | 一行代码 |
**建议**:A + B 组合。B 可复用 `main.py:61-65` 现有的 dev secret 告警模式,改动极小。
#### C3 审计 / 限流 fail-open 策略不可配置
`utils/authz.py:11-12` 注释自陈"降级口径与 T-02 审计一致……生产可切 fail-closed(**待决,已登记**)"。
当前所有审计失败都是 fail-open(仅 `logger.exception`),业务语义不变。合规强场景可能需要 fail-closed。
**建议**:纳入 §5 待确认事项,需要业务方明确"审计丢失"与"业务中断"哪个更不可接受。技术上是加一个配置项的事,但**这是业务决策不是技术决策**。
#### C4 无 DeepSeek key 时静默降级
`agent_service.py:148-150`:`if not settings.deepseek_api_key: reply = _degraded_reply(state)`,返回带 `_DEGRADED_PREFIX` 的摘要,**不抛异常、不告警**。
**风险**:生产漏配 key 时,系统"看起来正常",只是所有回答都是降级内容。用户和运维都难以察觉。
| 方案 | 做法 | 代价 |
| --- | --- | --- |
| **A(推荐)** | 启动时若 key 缺失,打 WARNING 日志(可复用 `lifespan` 里 `jwt_ready()` 的模式) | 一行代码 |
| B | 降级回复中明确告知用户"当前为降级模式" | 影响演示体验 |
| C | 生产环境 key 缺失直接启动失败 | 影响演示/CI |
**建议**:A。`main.py` 已有 `jwt_ready()` 的启动告警先例,风格统一。
---
### D 类 · 可观测性
#### D1 `utils/logger.py` 完全占位
文件内容仅 `"""日志模块。"""` 一行。全站 `logger.warning/exception` 走 Python 默认配置,**只输出到控制台,无格式化、无落盘、无轮转、未接 trace_id**。
对比:审计(`audit_log`)做得相当扎实——六处落点、只 INSERT、结构化可查。日志侧则是空白。
| 方案 | 做法 | 收益 | 代价 |
| --- | --- | --- | --- |
| A | 配置 `logging.basicConfig` + 文件 handler + 格式化串带 trace_id | 排障能力质变 | 需注入 trace_id 到 LogRecord(可用 filter) |
| B | 引入 `structlog` / `loguru` | 结构化日志 | 新增依赖,需确认(技术选型硬阀门) |
| C | 维持现状 | 零成本 | 线上问题基本无法追溯 |
**建议**:A 起步。用 `logging.Filter` 把 `current_trace()` 注入每条日志记录,是收益/成本比最高的改动,且无需新增依赖。
---
### E 类 · 可维护性
| ID | 问题 | 方案 | 代价 |
| --- | --- | --- | --- |
| E1 | 权限逻辑分散三处(矩阵 `deps.py:46` / chat 显式 deny `chat.py:122` / tool fail-closed `tool_service.py:138`) | A:维持三层纵深,但在文档固化"三层各自职责"<br>B:收拢到一处配置 | A 零成本<br>B 改动大,且会削弱纵深防御 |
| E2 | Tool 注册表分裂为 core / risk / kb 三套 | A:抽统一 `ALL_TOOLS` 聚合器 | 低风险,但收益有限 |
| E3 | `model/` 闲置(31 个 ORM 类不用),跨层全 dict 传参 | A:逐步用 `TypedDict` 约束仓储入参<br>B:维持现状 | A 渐进式,无编译期风险<br>B 保持敏捷但拼写错误运行时才暴露 |
| E4 | 注入词表硬编码 45 条 | A:配置化(`.env` 或 DB)<br>B:维持现状 + 持续红队补充 | A 需设计加载与热更新 |
**E1 建议**:A。三层不是冗余,是纵深防御——① 是能力声明、② 是业务线约束、③ 是兜底。收拢反而危险。只需写清文档避免后人"好心合并"。
**E3 建议**:A 渐进式。优先给 `insert_alert` / `insert_audit_log` 这类高频入口加 `TypedDict`。
---
### F 类 · 能力增强(非缺陷)
#### F1 支持 `convert` 转换交易(用户指定纳入)
**现状**:
- `scripts/core/01-ddl.sql:152`:`trade_type ENUM('subscribe','redeem','convert')` —— **数据库支持**
- `trade_gateway.py:36`:`SUPPORTED_TRADE_TYPES = ("subscribe", "redeem")`
- `trade_gateway.py:112-113`:`if trade_type == "convert": raise UnsupportedTradeType(CONVERT_MESSAGE)`
- 提示语:`"转换交易暂不支持,请分别发起申购/赎回"`
即:**Core(模拟真实交易系统)有这个能力,网关主动收窄了**。
**为什么当前不支持(代码层面的硬约束)**:
1. 请求体 `req` 只有 `{customer_id, product_id, trade_type, amount}` —— **单个 `product_id`**
2. `check_suitability(customer_id, product_id)` 只接受一个产品 —— 转换需要校验**转入那只**是否匹配客户风险等级
3. `amount` 语义会分裂:申购/赎回时是金额,转换时是份额
**方案对比**:
| 方案 | 做法 | 收益 | 代价 / 风险 |
| --- | --- | --- | --- |
| **A** | 请求体加 `target_product_id`,`convert` 时校验转入产品适当性,转出产品走持仓校验 | 业务完整,符合真实交易系统能力 | **改接口协议(需确认)**;引擎规则要重新定义(转换算不算当日累计?算几笔?);审计口径、前端都要跟着改 |
| **B** | 维持不支持,仅优化提示语与文档说明 | 零风险 | 业务缺口保留 |
| **C** | 网关层把 convert 拆解为 redeem + subscribe 两笔内部交易 | 不改接口协议,复用现有校验 | 语义变化大(变成两笔交易),`trade_id` 与审计口径都要变;金额换算复杂 |
**建议**:本轮不做,列入待确认。若要做,**方案 A 是唯一正确的**——C 会把一笔业务变成两笔,在金融审计上是灾难。
> **⚠️ 除网关外还有两处硬编码过滤(第一轮审核补充,最易漏)**
> `core_ro.py:390`(`list_trades_range`)与 `:432`(`sum_trades_on_date`)的 SQL 里写死:
> ```sql
> AND trade_type IN ('subscribe', 'redeem')
> ```
> 即便网关放开 convert,**这两处不改的话,convert 交易在 RISK-001(大额)/ RISK-002(当日累计)/ RISK-003(频繁交易)的聚合统计中会完全"隐形"**——规则引擎压根看不到它,等于给大额与累计规则开了一个绕过口子。
> 这是放行 convert 时最容易漏掉的一处,且**漏了不会报错**,只会静默少算。
**前置依赖**:需产品明确三件事:① 转换的适当性校验对象是转入产品还是两只都校验;② 转换金额是否计入 `risk_daily_total` 当日累计;③ 转换在 RISK-003 频繁交易规则中算一笔还是两笔。
#### F2 意图识别为关键词匹配
`tool_service.py:100-112` `match_intent`:按 agent 分组遍历关键词,`any(k in message for k in kws)`,**命中即停**(单意图)。
- 换说法即漏触("我的基金" vs "持仓")
- 不支持多意图("我的持仓和适当性"只取首条)
| 方案 | 收益 | 代价 |
| --- | --- | --- |
| A:换 LLM function calling | 支持多意图、泛化好 | 增加一次 LLM 调用延迟与成本;判定不可复现,不利于审计 |
| B:扩充同义词表 + 支持多意图 | 保持零延迟、可复现、可单测 | 仍需人工维护词表 |
| C:混合(关键词优先,未命中降级 LLM) | 兼顾 | 实现复杂度最高 |
**建议**:若命中率是痛点,先做 B(成本低、保持可审计性);A 的"不可复现"在金融审计场景下是明显劣势。
#### F3 R-05 动态评分未实现
`scoring.py` 抛 `NotImplementedError`,签名已冻结。L3 `risk_score` 一期恒 NULL。是否做取决于产品排期。
---
### G 类 · 非缺陷(设计正确,仅需文档标注)
| ID | 事项 | 为什么不是问题 |
| --- | --- | --- |
| G1 | `X-Trace-Id` 可前端伪造 | 设计如此:有格式白名单 `^[A-Za-z0-9._-]{1,64}$` 防响应头注入;**身份只认 JWT**,trace_id 从不用于权限判定。需在文档写明"不得用作权限凭据" |
| G2 | `trace_id` 概率唯一(`uuid4().hex[:16]` = 64 bit),无冲突检测 | 请求级 ID,按生日悖论约 2^32 次请求才有 50% 碰撞;真撞了只是两条日志串一起,不影响业务数据。**无需数据库唯一校验** |
| G3 | `admin.py` / `knowledge.py` 空壳 | T-21 拍板"一期只做脚本入库"。二者未 `include_router`,不影响启动。注意 `knowledge.py` 空 ≠ RAG 缺失(能力在 `service/rag_service.py`) |
---
## 4. 风险点与回归验证步骤
本节针对"若决定实施上述改动"给出。**本轮未改动任何代码,以下为预案。**
### 4.1 通用风险
| 风险 | 说明 | 缓解 |
| --- | --- | --- |
| 测试基线漂移 | 当前基线 503 绿 | 任何改动前先跑全量确认基线,改动后对比 |
| 路由清单失配 | `tests/test_main.py::test_all_routers_mounted` 硬编码路由清单 | 增改路由必须同步更新该清单,否则必红 |
| SQLite / MySQL 差异 | 单测用 SQLite,生产 MySQL | 涉及 DDL / 日期函数的改动需双跑 |
| 双库事务 | 跨库无法 JOIN、无分布式事务 | 任何跨库改动只能最终一致,需补偿机制 |
### 4.2 分类回归清单
**若改 A 类(文档口径)**
- 无代码改动 → 无需回归测试
- 验证:人工核对 `MEMORY.md` 与代码计数一致
**若改 B1(加 UNIQUE 索引)**
1. 先执行 `SELECT session_id, seq_no, COUNT(*) FROM agent_message GROUP BY session_id, seq_no HAVING COUNT(*) > 1` 确认历史无重号
2. 有重号 → 先清洗再建索引
3. 建索引后跑全量 pytest
4. 并发用例:模拟同会话并发 `insert_turn`,确认抛异常而非静默重号
5. 验证 `list_messages_page` 分页不受影响
**若改 B2(加中间件顺序测试)**
1. 新增测试:发起任意请求 → 断言 `audit_log` 中该行 `trace_id` 非空
2. 反向验证:临时调换装饰器顺序 → 测试应变红(确认测试有效)
3. 恢复顺序 → 测试转绿
**若改 B5(限流 EXPIRE)**
1. 单测覆盖:首次请求后键存在且带 TTL
2. 模拟 EXPIRE 失败 → 确认不产生无 TTL 键
**若改 C1(只读账号)**
1. 拆分连接串:`core_ro` 用只读账号,`gateway_repository` 用可写账号
2. 验证 `submit_trade` 仍能写 `core_trade`
3. 验证 `core_ro` 所有查询正常
4. 尝试用 `core_ro` 执行 INSERT → 应被数据库拒绝
5. 全量 pytest + uvicorn 冒烟三端点
**若改 C4(启动告警)**
1. 清空 `.env` 的 `DEEPSEEK_API_KEY` → 启动应打 WARNING
2. 填回 → 无告警
**若改 D1(日志配置)**
1. 验证日志落盘
2. 验证 trace_id 出现在每条记录中
3. 验证并发请求下 trace_id 不串号(关键:contextvar 传播)
**若改 F1(支持 convert)**
1. 接口协议变更需先经确认
2. 单测:convert 正常路径、转入产品适当性不匹配路径、持仓不足路径
3. 引擎回归:确认转换对 RISK-001/002/003 的计数影响符合产品定义
4. 审计回归:确认 convert 落审计的 `decision` 取值正确
5. 前端/演示脚本同步
### 4.3 最小回归命令
```bash
# 1. 建立基线
python -m pytest -q # 期望 503 passed
# 2. 启动冒烟
uvicorn app.main:app --reload
curl http://127.0.0.1:8000/health # 期望 {"status":"ok",...}
# 3. 接口实调(按演示 SOP)
# - POST /api/simulate/trade 阻断路径 + 放行路径
# - POST /api/chat 同步对话
# - POST /api/chat/stream SSE 流式(确认 [DONE] 与落库)
# - GET /api/risk/alerts 台账只读
# 4. 改动后重跑 1~3,对比基线
```
---
## 5. 待确认事项(需人工拍板,本轮不擅自决定)
| # | 事项 | 为什么需要拍板 | 关联 ID |
| --- | --- | --- | --- |
| 1 | 审计失败是否从 fail-open 切 fail-closed | 业务决策:审计丢失 vs 业务中断,哪个更不可接受 | C3 |
| 2 | 是否支持 `convert` 交易类型 | 改接口协议;需产品定义三条业务口径(见 F1) | F1 |
| 3 | 是否为 `core_ro` 建 DB 只读账号 | 需运维配合;连接串要拆分 | C1 |
| 4 | 意图识别是否升级为 LLM | 命中率 vs 可审计性的取舍 | F2 |
| 5 | 是否引入日志库(`structlog` / `loguru`) | 涉及技术选型硬阀门,新增依赖需确认 | D1 |
| 6 | `insert_turn` 并发控制:加唯一索引 or 引入会话锁 | 方案选择,涉及 DDL 变更;且须同步加重试(见 B1) | B1 |
| 7 | 生产部署拓扑:单进程 or 多实例 | `locks.py` 进程内锁、`insert_turn` 取号竞态**均以单进程为前提**;多实例时 B6 锁直接失效、B1 触发概率显著上升。此项不拍板,B1/B6 的方案选择无从谈起(第一轮审核补充) | B1 / B6 |
---
## 6. 给审核 AI 的检查清单
请逐项核验并在审核意见中给出结论:
**事实核验(对照代码,勿信本文断言)**
- [ ] §2 表中每一项的状态(未修复 / 待确认 / 非缺陷)是否与代码一致
- [ ] A1~A5 的"文档口径 vs 实测"数值是否属实(尤其是 A1 的 45 条、A2 的 5 个 Tool)
- [ ] B1 的并发重号问题:确认 `agent_message` 索引确为普通 KEY 而非 UNIQUE,确认 `insert_turn` 的 SELECT 未加锁
- [ ] B2 的中间件栈序推导是否正确(Starlette `add_middleware` 的 `insert(0)` + `build_middleware_stack` 的 `reversed`)
- [ ] C1 是否真的无法写 Core(确认无 DB 级只读账号、无 SQL 层拦截)
- [ ] F1 中"DB 支持 convert 但网关拒绝"是否属实
**方案评估**
- [ ] 每条"建议"是否与其代价分析自洽(有无只谈收益不谈代价)
- [ ] B1 推荐方案 A(加唯一索引)是否会破坏现有数据或测试
- [ ] C1 推荐方案 A(只读账号)是否与 `gateway_repository` 需写 Core 冲突,拆分连接串是否可行
- [ ] E1 建议"维持三层权限纵深"是否合理,还是确实应该收拢
**遗漏检查**
- [ ] 是否存在本文未覆盖的正确性或安全性问题
- [ ] §4 回归清单是否覆盖了所有高风险改动
- [ ] §5 待确认事项是否有遗漏(尤其是需要业务方而非技术方决策的)
**结论要求**
- 明确给出:可实施 / 需修改后实施 / 不建议实施
- 若发现本文事实性错误,请直接指出并给出正确结论