2026-09-09 18:10:03 +08:00
# 改进实施计划 · 全量版
> **文档状态**:实施计划,**尚未动代码**。待审核通过后按批次实施。
> **代码基线**:分支 `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 14:45:55 +08:00
> **状态(2026-09-10)**:✅ **已由基金转换线接手定案,并扩展为 D20**(账号矩阵 `xh_core_ro`/`xh_core_rw`/`xh_agent_rw` +
> `get_engine(db, role)` + 3 个权限断言 + **T-0b 阻断前置**)。落地清单见
> `docs/项目框架设计/架构设计-基金转换交易.md` **§11.1**。**本节原始评估保留备查,勿重复设计。**
2026-09-09 18:10:03 +08:00
需运维配合。注意:`core_ro.py:55` 与 `gateway_repository.py:24` **共用 `get_engine(settings.mysql_core_database)`** ,要拆成两个连接串(只读账号 + 可写账号)。
### 5.4 F1 · 支持 `convert`
2026-09-10 15:04:13 +08:00
> **状态(2026-09-10) **:✅ **已立项为独立线**( PRD v0.9 + 架构 v1.0 定稿,**代码未动**),开工入口 项目根 `交接文档.md` §B。
2026-09-10 14:45:55 +08:00
> 本节指出的**两处硬编码正是该线 D7 `_amount_view` 要解决的问题**( `core_ro.py:390`/`:432` 的 `trade_type` 白名单,
> 不改则 convert 在 RISK-001/002/003 聚合中隐形且不报错)。本线不再跟进,本节保留备查。
2026-09-09 18:10:03 +08:00
**除网关外还有两处硬编码** (最易漏):`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 "不改的范围"是否列全?
**结论要求** :明确给出 可实施 / 需修改后实施 / 不建议实施。若发现设计缺陷,请直接给出替代方案。