Files
group_xinghuo_jinrong/docs/项目框架设计/开发计划-架构改进.md
GaoYiYuan_0626 497cee289f docs: 交接文档合并为单一入口 + 全仓指向统一到 交接文档.md §A/§B/§C
背景:交接文档的定位是「给 AI 接手用的入口」,此前三份分散(项目根 交接文档.md
+ docs/交接文档-基金转换.md + docs/交接文档-架构改进.md),且 docs/ 两份停留在旧版
(v1.0 / v1.1,不含 T-0~T-2b、609 passed、D20 实施等进度),新会话极易被误导。

改动:
- 三份合并为项目根 交接文档.md v3.0(545 行 = §0 公共层 + §A 风控主线 + §B 基金转换线
  + §C 架构改进线);该文件在 .gitignore:47 内,按用户要求不入库(交接文档只留本地)
- 全仓指向统一到 交接文档.md §A/§B/§C:AGENTS.md(含顶部新增「接手先读交接文档.md」)、
  docs/memory/{MEMORY,TODO,FRAMEWORK,ITERATION}、PRD-架构改进与稳定性加固、
  开发计划-架构改进、TODO-架构改进、开发计划-基金转换交易
- 两份 docs/交接文档-*.md 加「已废弃(2026-09-10)· 勿读」横幅并指向新入口,
  保留作历史留档(不删除)
- 记录事故:对 docs/ 下中文名文件使用 git rm 会静默抹除整个 docs/ 目录(复现 2 次、
  退出码 0),已零损失恢复;纪律写入 交接文档.md §0.4 与工作区记忆

无代码改动;pytest 609 passed / 3 skipped。
2026-09-10 15:04:13 +08:00

486 lines
26 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.
# 改进实施计划 · 全量版
> **文档状态**:实施计划,**尚未动代码**。待审核通过后按批次实施。
> **代码基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`,pytest 503 绿。
> **覆盖范围**:《改进方案评审-问题清单与对比.md》中**全部 24 项**(21 项原始 + 第一轮审核补充 3 项),另含用户本轮指定的 Redis 分布式锁与告警补充。
> **关键约束**:用户明确"马上合并代码了" → **合并前只做零/低风险项**,结构性重构一律后置。
> **上游文档**:《架构设计说明书.md》(事实来源)、《改进方案评审-问题清单与对比.md》(已过第一轮审核,无阻断级错误)。
## 审核记录
### 第二轮(2026-09-09,独立 AI 全新上下文审核本计划)
**总体结论:需修改后实施(小改)。** 计划主体可行、降级链成立、24 项覆盖完整;Lua 脚本形式、FakeGateway 降级、分批策略**均经代码核验成立**。发现 1 处事实错误 + 1 处设计 caveat + 2 项可选,**本轮已全部吸收**。
| # | 审核意见 | 档位 | 本轮处理 |
| --- | --- | --- | --- |
| 1 | `run_locked` 调用点是**三处**不是两处,漏了 `alert_service.py:239` 的 `agg:suitability:` | 重要 | 已更正为三处(§4.1.1、§8.2) |
| 2 | TTL=10s 引入"临界区超时导致互斥丢失"这一**当前不存在的新失败模式**;计划称"远超毫秒级实测"但无实测数据 | 重要 | TTL 上调至 30 秒,并补入风险表(§4.1.5、§4.1.7) |
| 3 | 合并前须确认 `locks.py` / `redis_gateway.py` 在目标分支无在途改动 | 重要 | 已补入合并前检查(§8.4) |
| 4 | C4 改动需在 `main.py` 新增 `agent_service` 引用 | 可选 | 已补提醒(§3.3) |
| 5 | 新增锁测试应覆盖三处 key 形态,不能只测两处 | 可选 | 已补充(§8.2) |
**未吸收意见**:无。
> **关于意见 1 的说明**:此处是本文初版的实际疏漏——`grep run_locked` 的输出中本已包含 `alert_service.py:239`,撰写时漏计。该调用点是 **R-02 适当性阻断预警**的聚合锁,属最关键业务路径之一,若实施时漏改会导致"唯一阻断点"的聚合失去保护。审核此项有实质价值。
---
## 0. 结论速览
| 批次 | 项数 | 内容 | 时机 | 风险 |
| --- | --- | --- | --- | --- |
| **第 1 批** | 9 | A 类文档口径 ×5、G 类文档标注 ×3、C4 无 key 告警 | **合并前** | 极低(文档 + 1 处日志) |
| **第 2 批** | 2 | B6 Redis 分布式锁、B2 中间件测试守卫 | **合并前** | 中 / 低 |
| **第 3 批** | 5 | B1 唯一索引+重试、B5 限流 EXPIRE、C1 只读账号、F1 convert、F2 意图识别 | 合并后(需拍板) | 中~高 |
| **第 4 批** | 8 | D1 日志基建、E1~E4 可维护性、F3 动态评分、B3/B4 判据固化 | 合并后 | 中~高 |
| **合计** | **24** | | | |
**本批(合并前)只做第 1、2 批共 11 项**,理由是其余项或需拍板、或涉及 DDL、或属结构性重构,放在合并窗口内风险不可控。
---
## 1. 背景与约束
### 1.1 决策链
1. 本轮产出《架构设计说明书》,§7.3 列出 11 项改进空间、§9.4 列出 5 处文档口径不符
2. 补充发现 3 项(B1 seq 并发重号、D1 日志占位、F1 convert 能力收窄)
3. 第一轮独立 AI 审核:六项核心断言全属实,**无阻断级错误**,提 3 重要 + 2 可选,已全部吸收
4. 用户拍板:补告警、换 Redis 分布式锁,理由是"马上合并代码"
5. 用户补充:更新文档需覆盖**所有**改动项,不止这两项
### 1.2 硬约束
| 约束 | 影响 |
| --- | --- |
| 马上合并代码 | 合并前改动必须保守、可回滚、不触碰结构 |
| 五条红线 | Core 只读、审计只 INSERT、不自动冻结/改评级、仅 R-02 可阻断、四 Agent 不互调 LLM |
| pytest 503 绿 | 任何改动后必须仍全绿 |
| 改路由需同步 | `tests/test_main.py::test_all_routers_mounted` 硬编码路由清单 |
| 测试不依赖 Redis | `redis_gateway` 一律 fake 注入,新代码不得假设 Redis 可用 |
| 新增依赖需确认 | 技术选型硬阀门 |
---
## 2. 分批策略与理由
```
合并前 ── 第1批:文档口径/标注 + 告警 (不动逻辑,零回归风险)
└─ 第2批:Redis锁 + 测试守卫 (用户指定;双层降级保证不劣化)
合并后 ── 第3批:需拍板或涉及 DDL 的项
└─ 第4批:结构性重构
```
**为什么 B6(Redis 锁)放在合并前**:用户明确指定。设计上采用双层降级,最坏情况退回当前行为,不会比现状差。
**为什么 B1(唯一索引)不放合并前**:涉及 DDL 变更,且需先核查历史数据有无重号;合并窗口内做 DDL 风险过高。
**为什么 D1(日志基建)放最后**:它是全局性改动,会触及所有模块的日志调用,最适合在合并完成、分支稳定后单独做。
---
## 3. 第 1 批:合并前必做(极低风险,9 项)
### 3.1 A 类 · 文档口径修正(5 项,纯文档)
| ID | 文件 | 现状 → 修正 |
| --- | --- | --- |
| A1 | `docs/memory/MEMORY.md` §0 | "注入词表 42 条" → **45 条**(实测:指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7) |
| A2 | `docs/memory/MEMORY.md` 仓库地图 | "风控四 Tool" → **5 个**(`query_overdue_alerts` / `alert_query` / `customer_context` / `suitability_check` / `aml_lookup`;`query_agent_behavior` 定义未注册) |
| A3 | `docs/项目框架设计/表设计/02-mysql-agent专用.sql` 注释 | "5 张" → **6 张**(多出 `risk_aml_list`) |
| A4 | 架构说明书 §4.3.2 | 补明 `scoring.py` 为 `NotImplementedError` **预留桩**,L3 `risk_score` 一期恒 NULL |
| A5 | 架构说明书 §4.3.4 | 补明 `locks.py` 为**进程内**锁,多实例失效为 TODO(本方案第 2 批解决) |
**验证**:人工核对文档与代码计数一致,无需跑测试。
### 3.2 G 类 · 非缺陷标注(3 项,纯文档)
这三项**看起来像问题但设计正确**,写进架构说明书可避免后续被重复提出:
| 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`) |
### 3.3 C4 · 无 DeepSeek key 启动告警
**现状**(`agent_service.py:148-150`):无 key 时静默返回 `_degraded_reply`,不抛异常不告警。生产漏配 key 会"看起来正常"。
**改动**(`app/main.py` lifespan,紧跟现有 `JWT_DEV_SECRET` 告警之后):
```python
if not settings.deepseek_api_key:
logger.warning(
"DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 %s),LLM 能力不可用",
agent_service._DEGRADED_PREFIX,
)
```
**设计要点**:启动期一次性告警,不阻塞启动,沿用 `main.py:61-65` 现有 dev secret 告警的模式与位置。不在请求路径告警(会刷屏)。
> **实施提醒**(第二轮审核补充):`main.py` 需新增对 `app.service.agent_service` 的引用以取 `_DEGRADED_PREFIX`(该常量位于 `agent_service.py:37`)。属低风险 import,但需确认不引入循环导入——若出现,改为直接写字面量常量。
**风险**:无。仅新增日志。
### 3.4 审计降级告警强化
**现状**:`utils/authz.py:70-76` 与 `audit_middleware.py:78` 已有 `logger.exception/warning`,但**未打印 trace_id**——审计失败时连是哪次请求都不知道。
**改动**:两处补 `trace_id` 与关键上下文:
```python
# utils/authz.py
logger.exception(
"authz 审计失败(已降级,拒绝语义不变):code=%s agent=%s actor=%s trace_id=%s",
code, agent_type, actor_id, current_trace(),
)
# api/audit_middleware.py
logger.warning(
"http_access 审计失败(降级不阻塞):status=%s path=%s trace_id=%s",
status, request.url.path, current_request_id(),
exc_info=True,
)
```
**说明**:Python 默认 root logger 会输出 WARNING 及以上到 stderr,uvicorn 控制台**能看到**,所以本改动即使不落地 D1 也有价值。D1(格式化 + 落盘)属第 4 批。
**风险**:无。仅改日志内容。
### 3.5 本批不涉及代码逻辑改动
第 1 批 9 项中,**只有 C4 和 3.4 动代码,且都只加日志**,不改任何业务分支。
---
## 4. 第 2 批:合并前做(2 项)
### 4.1 B6 · Redis 分布式锁(用户指定)
#### 4.1.1 现状
`app/service/risk/locks.py:18-40` 用 `threading.Lock`,只活在单进程内存里,跨进程不可见。多实例部署时两实例各有各的锁,同一客户并发请求会同时进入临界区。
**调用点共三处**(第二轮审核更正,初版漏计第三处):
| # | 位置 | key 形态 | 保护对象 |
| --- | --- | --- | --- |
| 1 | `alert_service.py:180` | `agg:event:{customer_id}:{date}` | 交易事件预警聚合首单 |
| 2 | `alert_service.py:239` | `agg:suitability:{customer_id}:{product_id}:{date}` | **R-02 适当性阻断预警聚合** |
| 3 | `profile_l3.py:155` | `l3:{customer_id}` | L3 监测层更新 |
> **第 2 处尤其不能漏**:它是 `record_suitability_alert`(R-02 阻断路径)的聚合锁,而 R-02 是全系统**唯一能阻断交易**的规则。若实施时漏改,这条最关键路径的聚合将失去并发保护——同客户同产品同日可能出多张重复阻断单。
**现有兜底**:L3 侧另有数据库乐观锁(`computed_at` 比对 + 3 次重试),跨进程天然有效,最坏是"多出一张重复预警单",非数据错乱。
#### 4.1.2 三条硬约束(决定设计)
**① 测试完全不依赖本机 Redis** —— `tests/test_integration_risk.py:6` 明确写"redis_gateway fake(不依赖本机 Redis)",多测试用 `monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway())` 注入。新代码**绝不能假设 Redis 可用**。
**② FakeGateway 只有部分方法** —— 只实现 `publish`/`delete`/`exists`/`incr`/`rpush`/`lrange`/`ltrim`/`expire`,**不会有 `acquire_lock`/`release_lock`**。调用会抛 `AttributeError`,设计上须归类为"Redis 不可用"。
**③ 测试无直接 `run_locked` 用例** —— 锁是间接被测的,需新增针对性测试。
#### 4.1.3 设计:双层锁,Redis 为主、进程内为备
```
run_locked(key, fn)
├─ 尝试 Redis 锁(SET key token NX EX ttl)
│ ├─ 拿到 → fn(locked=True),finally 释放 ✅ 跨进程互斥
│ ├─ 等待超时未拿到 → 告警 → fn(locked=False) ⚠ 保持原降级语义
│ └─ Redis 不可用 → 落到进程内锁 ↓ ⚠ 单进程内仍互斥
└─ 进程内锁(threading.Lock,原逻辑原样保留)
├─ 拿到 → fn(locked=True)
└─ 超时 → fn(locked=False)
```
**为什么保留进程内锁**:Redis 挂掉时若直接无锁,连"单进程串行保护"也丢了。保留双层,最坏退回**当前行为**,不会更差。
#### 4.1.4 代码设计
```python
# app/service/risk/redis_gateway.py —— 新增两个方法(纯新增,不动现有方法)
class RedisGateway:
def acquire_lock(self, key: str, token: str, ttl_seconds: int) -> bool:
"""SET key token NX EX ttl:抢到返回 True,已存在返回 False。"""
return bool(self._ensure().set(key, token, nx=True, ex=ttl_seconds))
def release_lock(self, key: str, token: str) -> bool:
"""Lua 脚本释放:只删自己持有的锁,防误删他人锁。"""
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
return bool(self._ensure().eval(script, 1, key, token))
```
> **为什么释放必须用 Lua**:`GET` 判断 + `DEL` 删除两步,在 A 锁超时后 B 拿到锁时,A 的 DEL 会误删 B 的锁。Lua 保证"读-比对-删"原子。
```python
# app/service/risk/locks.py —— 改造 run_locked
LOCK_TIMEOUT_SECONDS = 2.0 # 获取锁的等待上限(保持原值、原语义)
LOCK_TTL_SECONDS = 30 # 新增:锁持有上限,防进程崩溃后死锁(取值理由见 §4.1.5)
_LOCK_KEY_PREFIX = "lock:" # 新增:与 sess:/ratelimit:/auth: 命名空间隔离
_RETRY_INTERVAL = 0.05 # 新增:抢锁重试间隔
def run_locked(key: str, fn: Callable[[bool], Any]) -> Any:
"""锁内执行 fn(locked=True);Redis 不可用时退回进程内锁;超时降级 fn(locked=False)。"""
token = uuid4().hex
outcome = _acquire_redis(_LOCK_KEY_PREFIX + key, token) # acquired/timeout/unavailable
if outcome == "acquired":
try:
return fn(locked=True)
finally:
_release_redis(_LOCK_KEY_PREFIX + key, token)
if outcome == "timeout":
logger.warning("分布式锁等待超时,降级执行(冲突由调用方兜底):%s", key)
return fn(locked=False) # 与原语义一致
# unavailable:Redis 不可用 → 退回进程内锁(原逻辑原样保留)
lock = lock_for(key)
if not lock.acquire(timeout=LOCK_TIMEOUT_SECONDS):
logger.warning("进程内锁超时,降级执行(冲突由调用方兜底):%s", key)
return fn(locked=False)
try:
return fn(locked=True)
finally:
lock.release()
```
`_acquire_redis` 内部轮询至 `LOCK_TIMEOUT_SECONDS` 耗尽;**任何异常(含 FakeGateway 的 `AttributeError`)一律归类为 `unavailable` 并记日志,绝不向上抛**。
#### 4.1.5 参数取值
| 参数 | 值 | 理由 |
| --- | --- | --- |
| `LOCK_TIMEOUT_SECONDS` | 2.0 | 保持原值,不改变现有降级时序 |
| `LOCK_TTL_SECONDS` | **30** | 见下方专项说明(第二轮审核由 10 上调) |
| `_RETRY_INTERVAL` | 0.05 | 2 秒窗口约 40 次尝试,兼顾响应与 Redis 压力 |
| key 前缀 | `lock:` | 与既有 key 命名空间隔离,避免误删业务 key |
**`LOCK_TTL_SECONDS` 取值专项说明(第二轮审核补充)**
原进程内锁 `threading.Lock` 一旦拿到就**持有到 `release()`,永不自动过期**。改用 Redis 锁引入 TTL 后,就引入了一个**当前不存在的新失败模式**:
> 若临界区执行耗时超过 TTL,Redis 自动删除 key,另一实例可抢到锁 → 两实例并发进入临界区。
这是 TTL 的固有代价(也是防"进程崩溃后锁永不释放"必须付的对价)。评估与缓解:
- **兜底仍在**:L3 有数据库乐观锁,预警聚合有"锁内重查锚点",最坏后果是重复 pending 单,**非数据损坏**,与"超时降级"同类
- **取值**:初版取 10 秒且未提供实测数据,本轮上调至 **30 秒**留安全边际
- **后续**:取得实测 p99 后按 `max(30, p99 × 5)` 调整
#### 4.1.6 涉及文件
| 文件 | 改动 |
| --- | --- |
| `app/service/risk/redis_gateway.py` | 新增 `acquire_lock` / `release_lock`(纯新增) |
| `app/service/risk/locks.py` | 改造 `run_locked` 为双层;新增常量与内部函数;`lock_for` 保留 |
| `tests/` | 新增锁测试(见 §8.2) |
**不改**:`alert_service.py` / `profile_l3.py` 的调用方式,`run_locked(key, fn)` 签名与 `fn(locked)` 语义完全不变。
#### 4.1.7 风险
| 风险 | 缓解 |
| --- | --- |
| 测试连不上 Redis 累积延迟 | 不可用即降级,不重试到超时;必要时加熔断标记(§9 待确认) |
| FakeGateway 缺方法抛 `AttributeError` | 归类 unavailable,降级进程内锁,测试行为不变 |
| 临界区耗时超 TTL(30 秒)→ Redis 自动解锁,两实例并发进入 | 这是 TTL 的固有代价,**当前单进程行为没有此模式**。兜底仍在(L3 乐观锁 / 锁内重查锚点),最坏为重复 pending 单;取得 p99 后按 `max(30, p99×5)` 调整(详见 §4.1.5 专项说明) |
| 测试期产生大量 `unavailable` 日志噪声 | 三处调用点在 503 测试中均走 Fake→`AttributeError`→unavailable 路径,被捕获不导致失败,但日志量会上升。实施前后对比基线确认不漂移即可 |
| Redis 极端延迟下双持锁 | 业务侧已有兜底:L3 乐观锁 + 预警锚点在锁内重查 |
### 4.2 B2 · 中间件执行顺序测试守卫
**现状**:`main.py` audit 先注册(81 行)、trace 后注册(87 行)。Starlette `add_middleware` 用 `insert(0)` 且 `build_middleware_stack` 反向包裹 → **数组越靠前越外层 → trace 在外层先执行**,audit 在内层,与其注释"执行序在 trace 之内"吻合。
**风险**:调换两个装饰器后,audit 会先执行,`current_trace()` 返回空串,**全站审计静默丢失 trace_id**,不报错不失败。
**改动**:新增测试,发起请求后断言 `audit_log` 中该行 `trace_id` 非空。
```python
def test_audit_middleware_runs_inside_trace_middleware(client, captured_audit):
"""守卫:audit 必须在 trace 之内执行,否则 trace_id 全空且不报错。"""
resp = client.get("/health") # 或任一非 _SKIP_PATHS 路径
rows = captured_audit()
assert rows, "应落 http_access 审计"
assert rows[-1]["trace_id"], "audit 若先于 trace 执行,此处会静默为空"
```
**为什么不放 `ensure_trace()` 兜底**:那会掩盖顺序错误,导致 trace_id 与响应头不一致,更难查。
**验证**:临时调换装饰器 → 测试应变红(确认测试有效)→ 恢复 → 转绿。
---
## 5. 第 3 批:合并后 · 需拍板或涉及 DDL(5 项)
> 以下各项**不在合并窗口内实施**,此处给出设计以供届时使用。
### 5.1 B1 · `agent_message` 唯一索引 + 重试
**问题**:`01-mysql-共用底座.sql` 中 `agent_message` 只有 `KEY idx_session_seq (session_id, seq_no)`(普通索引,非 UNIQUE);`insert_turn`(`session_repository.py:242`)在事务内取 `MAX(seq_no)+1` 但 SELECT 无 `FOR UPDATE` → 并发同会话**静默产生重号消息**,LLM 上下文乱序。
**实施**(两步必须同时做):
1. **DDL**:先执行 `SELECT session_id, seq_no, COUNT(*) FROM agent_message GROUP BY 1,2 HAVING COUNT(*)>1` 确认无历史重号,有则先清洗;再加 `UNIQUE KEY uk_session_seq (session_id, seq_no)`
2. **代码**:`insert_turn` 捕获 `IntegrityError` → 重读 `MAX(seq_no)` → 重试(建议 3 次,与 L3 乐观锁一致)
> **审核强调**:只加索引不加重试,会把"静默脏数据"变成**并发 500 硬失败**,等于换个形式没解决。
**附带清理**:`session_repository.py:134` `next_seq_no()`(非事务 `.connect()` 取号)+ `:142` `insert_message()` 单独 INSERT 是**更危险的并行取号路径**(连事务都没有)。建议统一走 `insert_turn`,废弃该组合。
### 5.2 B5 · 限流 `INCR` + `EXPIRE` 非原子
首次命中先 `INCR` 再 `EXPIRE`,两步之间崩溃会留下无 TTL 计数键 → 该 actor 被永久限流。
**改法**:不做"首命中"判断,每次 `INCR` 后无条件 `EXPIRE`(一行级改动)。
### 5.3 C1 · `core_ro` DB 级只读账号
> **状态(2026-09-10)**:✅ **已由基金转换线接手定案,并扩展为 D20**(账号矩阵 `xh_core_ro`/`xh_core_rw`/`xh_agent_rw` +
> `get_engine(db, role)` + 3 个权限断言 + **T-0b 阻断前置**)。落地清单见
> `docs/项目框架设计/架构设计-基金转换交易.md` **§11.1**。**本节原始评估保留备查,勿重复设计。**
需运维配合。注意:`core_ro.py:55` 与 `gateway_repository.py:24` **共用 `get_engine(settings.mysql_core_database)`**,要拆成两个连接串(只读账号 + 可写账号)。
### 5.4 F1 · 支持 `convert`
> **状态(2026-09-10)**:✅ **已立项为独立线**(PRD v0.9 + 架构 v1.0 定稿,**代码未动**),开工入口 项目根 `交接文档.md` §B。
> 本节指出的**两处硬编码正是该线 D7 `_amount_view` 要解决的问题**(`core_ro.py:390`/`:432` 的 `trade_type` 白名单,
> 不改则 convert 在 RISK-001/002/003 聚合中隐形且不报错)。本线不再跟进,本节保留备查。
**除网关外还有两处硬编码**(最易漏):`core_ro.py:390`(`list_trades_range`)与 `:432`(`sum_trades_on_date`)写死 `AND trade_type IN ('subscribe','redeem')`。不改的话 convert 在 RISK-001/002/003 聚合中**完全隐形且不报错**。
**前置**:产品需明确三条口径(校验对象、是否计入当日累计、RISK-003 算几笔),且属接口协议变更。
### 5.5 F2 · 意图识别
当前 `tool_service.py:100-112` 关键词匹配、命中即停、单意图。若升级 LLM 路由,需权衡"不可复现"对审计的影响。**建议先扩同义词表 + 支持多意图**,保持零延迟与可单测。
---
## 6. 第 4 批:合并后 · 重构类(8 项)
| ID | 项 | 要点 |
| --- | --- | --- |
| D1 | 日志基建 | `utils/logger.py` 仅一行 docstring。用 `logging.basicConfig` + 文件 handler + `logging.Filter` 注入 trace_id(无需新依赖) |
| E1 | 权限逻辑分散三处 | **建议维持三层纵深**(矩阵=能力声明 / chat=业务线约束 / tool=兜底),只补文档,不收拢 |
| E2 | Tool 注册表分裂 | 抽统一 `ALL_TOOLS` 聚合器,低风险低收益 |
| E3 | `model/` 闲置 | 渐进式:给 `insert_alert`/`insert_audit_log` 等高频入口加 `TypedDict` |
| E4 | 注入词表硬编码 45 条 | 配置化 + 持续红队补充 |
| F3 | R-05 动态评分 | 实现 `scoring.recompute_customer_score`(签名已冻结),同步放开 L3 `risk_score` |
| B3 | `deny()` 双写无事务 | **建议维持现状**,在文档固化判据 |
| B4 | 出单双写无事务 | 同上 |
**B3/B4 判据(写入文档即可,不改代码)**:
> **新增可丢留痕,变更不可丢。** 出单/越权是新增,最多少一条审计,业务单据仍在;处置是变更,`handle_alert_with_audit` 必须同事务,否则"改了状态没记录"是合规红线。
---
## 7. 明确不改的范围
| 不做 | 原因 |
| --- | --- |
| C3 审计 fail-open → fail-closed 切换 | 用户已选"保持 fail-open + 补告警" |
| C2 dev debug 通道改造 | 现状有双闸门 + lifespan 校验,仅需在部署文档加警示 |
| 五条红线相关行为 | 任何改动不得触碰 |
| `run_locked` 对外契约 | 保持不变,避免波及两个调用点 |
| 新增第三方依赖 | Redis 库已在 `requirements.txt`,无需新增 |
---
## 8. 回归验证
### 8.1 基线(每次改动前后)
```bash
python -m pytest -q # 基线 503 passed
```
改动后必须**仍全绿**,新增测试计入总数。
### 8.2 第 2 批新增测试(须同步交付)
| 用例 | 断言 |
| --- | --- |
| Redis 可用时抢锁成功 | `fn(locked=True)`,Redis 侧 key 带 TTL |
| Redis 锁被占用 | 超时后 `fn(locked=False)`,降级告警出现 |
| Redis 抛 `ConnectionError` | 退回进程内锁,不抛异常,业务完成 |
| FakeGateway 缺方法(`AttributeError`) | 归类 unavailable,测试不红 |
| 释放只删自己的锁 | 错误 token 释放返回 False,锁仍在 |
| 并发互斥(可选,需真 Redis) | 两个进程抢同一 key,仅一个进入 |
| **三处调用点 key 形态全覆盖** | `agg:event:` / `agg:suitability:` / `l3:` 三种 key 均带 `lock:` 前缀且互不冲突(第二轮审核补充,避免只测两处) |
### 8.3 手工冒烟
```bash
uvicorn app.main:app --reload
# 1. 清空 .env 的 DEEPSEEK_API_KEY → 启动日志出现降级告警
# 2. 恢复 key → 无该告警
# 3. POST /api/simulate/trade 阻断 + 放行各一次
# 4. 预警聚合仍为"同客户同日仅一张 pending 单"
# 5. 停 Redis → 业务仍可完成(退进程内锁)
# 6. 恢复 Redis → 行为与停服前一致
```
### 8.4 合并前专项检查
- [ ] `tests/test_main.py::test_all_routers_mounted` 路由清单未受影响(本批不改路由)
- [ ] 未新增配置项 → 不改 `.env.example`(若决定配置化 `LOCK_TTL_SECONDS` 则需同步)
- [ ] 未新增第三方依赖
- [ ] **确认 `locks.py` / `redis_gateway.py` 在目标合并分支无在途改动**(第二轮审核补充:这两个是本次被改文件,有在途改动易冲突)
---
## 9. 待确认
| # | 事项 | 说明 | 倾向 |
| --- | --- | --- | --- |
| 1 | `LOCK_TTL_SECONDS` 是否配置化 | 硬编码 10 秒简单,但环境差异 | 先硬编码,合并后按需配置化 |
| 2 | 是否加"Redis 熔断"标记 | Redis 长期不可用时累积连接延迟 | 先不做,实测变慢再补 |
| 3 | 生产部署拓扑(单进程/多实例) | 决定 B1/B6 的紧迫性 | 已按"可能多实例"处理(B6 本批做) |
| 4 | 审计 fail-open/fail-closed | 用户已选 fail-open + 告警 | 已定 |
| 5 | 是否支持 convert | 需产品三条口径 | 合并后再议 |
| 6 | 日志是否落盘 | D1 范围 | 合并后 |
---
## 10. 给审核 AI 的检查清单
**分批合理性**
- [ ] "合并前只做第 1、2 批"的划分是否合理?有无应提前或推迟的项?
- [ ] B6 放合并前是否风险偏高?双层降级是否真能保证"不劣化"?
**设计正确性**
- [ ] Redis 锁 TTL(10 秒)与两个临界区耗时是否匹配?
- [ ] "Redis 不可用"与"超时"分开处理是否合理?超时直接 `fn(locked=False)` 会否在高并发下放大冲突?
- [ ] Lua 释放脚本在 redis-py 中的调用形式(`eval(script, 1, key, token)`)是否正确?
- [ ] `uuid4().hex` 作为 token 是否足够(同 key 两次持有必须不同)?
**兼容性**
- [ ] FakeGateway 缺方法抛 `AttributeError` → 归类 unavailable,这个判断稳吗?Fake 若实现 `__getattr__` 会怎样?
- [ ] 503 测试中有多少会走到 `run_locked`?改动后耗时是否显著变长?
- [ ] 是否影响 `test_integration_risk.py`(真 MySQL + TRD-TEST- 前缀隔离)?
**范围**
- [ ] 24 项是否覆盖完整?有无遗漏此前记录的问题?
- [ ] §7 "不改的范围"是否列全?
**结论要求**:明确给出 可实施 / 需修改后实施 / 不建议实施。若发现设计缺陷,请直接给出替代方案。