Files
group_xinghuo_jinrong/docs/项目框架设计/改进方案评审-问题清单与对比.md
GaoYiYuan_0626 037ce7edca docs(架构改进): 补齐 PRD/开发计划/TODO/交接文档,落地无密钥告警与 Redis 分布式锁
一、流程文档(按 AIcoding 六步落地,供新会话从交接文档开工)
- 新增 docs/PRD/PRD-架构改进与稳定性加固.md:6 条 FR(文档勘误、非缺陷说明、
  无密钥启动告警、审计失败告警、Redis 分布式锁、中间件顺序测试)
- 新增 docs/项目框架设计/改进方案评审-问题清单与对比.md:24 项问题分档 A~G,
  经两轮独立 AI 评审,无阻断级错误
- 新增 docs/项目框架设计/开发计划-架构改进.md:HOW 层设计,含合并前只做低风险
  11 项的批次策略
- 新增 docs/项目框架设计/TODO-架构改进.md:T-101~T-109、T-201~T-202 可勾选项
- 新增 docs/交接文档-架构改进.md:自包含交接入口,hy3 新会话可直接开工
- 新增 docs/项目框架设计/架构设计说明书.md:按模块/分层逐一讲解的全量架构说明

二、代码改动(T-107/108/109、T-201.1、T-201.2)
- app/main.py:启动时 DEEPSEEK_API_KEY 缺失告警,明确告知将走降级回复
- app/utils/authz.py:越权审计失败日志补 trace_id,便于串联全链路
- app/api/audit_middleware.py:审计失败日志补 status/path/request_id
- app/service/risk/redis_gateway.py:新增 acquire_lock(SET NX EX)与
  release_lock(Lua 原子释放,只删自己的锁)
- app/service/risk/locks.py:run_locked 改为双层锁,Redis 为主、进程内锁为备;
  Redis 超时沿用 fn(locked=False) 降级语义,Redis 不可用(含测试 Fake 缺方法的
  AttributeError)安全退回进程内锁,绝不抛异常

三、文档勘误(A1/A2/A3)
- MEMORY.md:文件数 42→45、Tools 4→5
- 02-mysql-agent专用.sql:会话表 5→6
- 架构设计-风控模块.md:同步更正

四、测试
- 新增 tests/test_locks_redis.py:覆盖抢锁成功、占用超时、Redis 故障降级、
  Fake 缺方法降级、只删自己锁、三处调用点 key 前缀
- tests/test_audit_middleware.py:补充告警字段断言
- 全量 pytest 510 passed(原基线 503)
2026-09-09 18:10:03 +08:00

489 lines
30 KiB
Markdown
Raw Permalink 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.
# 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 待确认事项是否有遗漏(尤其是需要业务方而非技术方决策的)
**结论要求**
- 明确给出:可实施 / 需修改后实施 / 不建议实施
- 若发现本文事实性错误,请直接指出并给出正确结论