背景:交接文档的定位是「给 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。
26 KiB
改进实施计划 · 全量版
文档状态:实施计划,尚未动代码。待审核通过后按批次实施。 代码基线:分支
risk-control-agent,HEAD2d0e2fa,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 决策链
- 本轮产出《架构设计说明书》,§7.3 列出 11 项改进空间、§9.4 列出 5 处文档口径不符
- 补充发现 3 项(B1 seq 并发重号、D1 日志占位、F1 convert 能力收窄)
- 第一轮独立 AI 审核:六项核心断言全属实,无阻断级错误,提 3 重要 + 2 可选,已全部吸收
- 用户拍板:补告警、换 Redis 分布式锁,理由是"马上合并代码"
- 用户补充:更新文档需覆盖所有改动项,不止这两项
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 告警之后):
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 与关键上下文:
# 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 代码设计
# 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 保证"读-比对-删"原子。
# 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 非空。
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 上下文乱序。
实施(两步必须同时做):
- 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) - 代码:
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 基线(每次改动前后)
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 手工冒烟
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 "不改的范围"是否列全?
结论要求:明确给出 可实施 / 需修改后实施 / 不建议实施。若发现设计缺陷,请直接给出替代方案。