# TODO · 架构改进与稳定性加固 > **用途**:开发执行清单。**按 T-1xx → T-2xx 顺序做**,每完成一项勾选并跑一次 pytest。 > **上游**:《docs/PRD/PRD-架构改进与稳定性加固.md》(做什么)·《docs/项目框架设计/开发计划-架构改进.md》(怎么做) > **基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`,**pytest 503 绿** > **开工前必读**:项目根《交接文档.md》§C > **实施记录(2026-09-09,hy3 执行 · 已复核)**:T-101~T-202 全部完成,pytest **510 绿**(503 基线 + 6 条锁测试 + 1 条中间件顺序守卫 = 新增 7 条);**未新增配置项/依赖/路由,三处 run_locked 调用点未改**。注意:《架构设计说明书》实际文件为 `docs/项目框架设计/架构设计-风控模块.md`(无 §4.3.x 章节),T-104/105/106 标注已落到 §2 树(scoring.py)、§5.2(locks.py)、§5.8(G1/G2/G3)对应位置。 > **提交**:`037ce7e`(commit 不 push,待用户浏览器目视确认后推送) > **遗留手工验证(未经执行,勿视为通过)**:T-107 清空/恢复 key 各启动一次、停/启 Redis 各一次、`/api/simulate/trade` 阻断+放行各一次。 **状态标记**:`- [ ]` 待办 · `- [x]` 已完成 · `- [/]` 进行中 --- ## 开工前检查(必做) - [x] 确认当前分支 `risk-control-agent`,HEAD 为 `2d0e2fa` - [x] 跑一次基线:`python -m pytest -q` → 记录实际数字(应为 503 passed) - [x] 确认 `locks.py` / `redis_gateway.py` 在目标合并分支**无在途改动** - [x] 读《交接文档.md》§C 全文(5 分钟,避免踩已记录的坑) --- ## 第 1 批 · 合并前(极低风险) ### T-101 · A1 注入词表条数修正 - [x] 改 `docs/memory/MEMORY.md` §0:`42 条` → `45 条` **验收**:与 `app/service/input_guard.py:43-94` 逐行计数一致(指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7 = 45) --- ### T-102 · A2 风控对话 Tool 数量修正 - [x] 改 `docs/memory/MEMORY.md` 仓库地图:`4 个` → `5 个` **验收**:与 `app/service/risk/chat_tools.py:328` `RISK_TOOL_REGISTRY` 一致(5 个:`query_overdue_alerts` / `alert_query` / `customer_context` / `suitability_check` / `aml_lookup`)。注:`query_agent_behavior`(276 行)定义但未注册,不是第 6 个。 --- ### T-103 · A3 Agent 专用表数量修正 - [x] 改 `docs/项目框架设计/表设计/02-mysql-agent专用.sql` 注释:`5 张` → `6 张` **验收**:与实建表一致(`customer_threshold_config` / `customer_notify_log` / `advisor_draft` / `compliance_hit_log` / `analytics_query_log` / `risk_aml_list`) --- ### T-104 · A4 标注 scoring.py 为预留桩 - [x] 在《架构设计说明书》§4.3.2 补明:`scoring.py` 的 `recompute_customer_score` 直接 `raise NotImplementedError`,是签名冻结的**预留桩**,非实际评分器;L3 `risk_score` 一期恒 NULL **验收**:读者不会误以为动态评分已生效 --- ### T-105 · A5 标注 locks.py 为进程内锁 - [x] 在《架构设计说明书》§4.3.4 补明:`locks.py` 用 `threading.Lock`,是**进程内**锁,多实例部署失效为 TODO **验收**:读者知道多实例场景需先处理此项(本清单 T-201 解决) --- ### T-106 · G1/G2/G3 非缺陷设计标注 - [x] G1:`X-Trace-Id` 可前端伪造 —— 写明有白名单 `^[A-Za-z0-9._-]{1,64}$` 防响应头注入,**身份只认 JWT,trace_id 绝不用于权限判定** - [x] G2:`trace_id` 概率唯一 —— 写明 `uuid4().hex[:16]` = 64 bit,请求级,约 2^32 次请求才 50% 碰撞,无需数据库唯一校验 - [x] G3:`admin.py` / `knowledge.py` 空壳 —— 写明 T-21 拍板一期只做脚本入库、未挂载路由、**空 ≠ RAG 缺失**(能力在 `service/rag_service.py`) **验收**:三项在《架构设计说明书》各有独立条目,含"为什么不是问题" --- ### T-107 · C4 无 key 启动告警 - [x] 在 `app/main.py` lifespan 新增告警(紧跟 `main.py:61-65` 现有 dev secret 告警之后) ```python if not settings.deepseek_api_key: logger.warning( "DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 %s),LLM 能力不可用", agent_service._DEGRADED_PREFIX, ) ``` **注意**:需引用 `app.service.agent_service` 的 `_DEGRADED_PREFIX`(位于 `agent_service.py:37`)。**若出现循环导入,改为直接写字面量常量**。 **验收**(前两项需手工,未执行): - [ ] 清空 `.env` 的 `DEEPSEEK_API_KEY` → 启动日志出现告警,**不阻塞启动** - [ ] 恢复 key → 无该告警 - [x] 无循环导入(采用函数内延迟 import;全量 pytest 中 `app.main` 被多用例正常导入,无 ImportError) --- ### T-108 · 审计降级告警强化(authz) - [x] 改 `app/utils/authz.py:70-76`,补 `trace_id` 等字段 ```python logger.exception( "authz 审计失败(已降级,拒绝语义不变):code=%s agent=%s actor=%s trace_id=%s", code, agent_type, actor_id, current_trace(), ) ``` **验收**:日志含 4 个字段;降级语义不变(仍不阻塞、不抛异常) --- ### T-109 · 审计降级告警强化(middleware) - [x] 改 `app/api/audit_middleware.py:78`,补 `status` / `path` / `request_id` **验收**:日志含上述字段;失败路径仍返回原响应,**行为零变化**(pytest 510 绿,原有审计用例无断言变化) --- ### 第 1 批完成检查 - [x] `python -m pytest -q` 全绿(实测 **510 passed**) - [x] T-107 手工冒烟通过(清空/恢复 key 各一次)—— **2026-09-10 补跑 ✓**:本机 `.env` 的 `DEEPSEEK_API_KEY` 本就为空(len=0),清空态直接验证:uvicorn 1s 起、`/health` 200、进程存活不阻塞、日志出现「DEEPSEEK_API_KEY 未配置,对话将走降级回复」;「恢复 key 无告警」一支因本机无真实 key,改用**非空占位值**验证条件分支(告警只判 `not settings.deepseek_api_key`,不校验有效性),未出现告警 ✓;验证后 `.env` 已还原 --- ## 第 2 批 · 合并前(核心改动) ### T-201 · B6 Redis 分布式锁 > ⚠️ **本项改动前三处调用点必须全部覆盖**(见下),漏改 `agg:suitability:` 会让 R-02 唯一阻断点失去并发保护。 **调用点(三处,勿漏)**: | # | 位置 | key | | --- | --- | --- | | 1 | `alert_service.py:180` | `agg:event:{customer_id}:{date}` | | 2 | `alert_service.py:239` | `agg:suitability:{customer_id}:{product_id}:{date}` | | 3 | `profile_l3.py:155` | `l3:{customer_id}` | - [x] **T-201.1** `app/service/risk/redis_gateway.py` 新增 `acquire_lock`(`SET key token NX EX ttl`)与 `release_lock`(Lua 脚本,只删自己的锁)—— **纯新增,不动现有方法** ```python def acquire_lock(self, key: str, token: str, ttl_seconds: int) -> bool: return bool(self._ensure().set(key, token, nx=True, ex=ttl_seconds)) def release_lock(self, key: str, token: str) -> bool: 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)) ``` - [x] **T-201.2** `app/service/risk/locks.py` 改造 `run_locked` 为双层(Redis 为主、进程内为备),新增常量: - `LOCK_TTL_SECONDS = 30`(锁持有上限) - `_LOCK_KEY_PREFIX = "lock:"`(命名空间隔离) - `_RETRY_INTERVAL = 0.05` - `LOCK_TIMEOUT_SECONDS = 2.0` **保持原值不动** **降级规则(三条,不可改)**: 1. Redis 抢到 → `fn(locked=True)`,finally 释放 2. Redis 等待超时 → `fn(locked=False)`(**与原语义一致**) 3. Redis 不可用(**含任何异常,含 Fake 缺方法的 `AttributeError`**)→ 退回进程内锁,绝不向上抛 - [x] **T-201.3** 新增测试(`tests/test_locks_redis.py`,6 条,无本机 Redis 依赖,全部走 monkeypatch 假网关): | 用例 | 断言 | | --- | --- | | Redis 可用抢锁成功 | `fn(locked=True)`,Redis key 带 TTL | | Redis 锁被占用 | 超时后 `fn(locked=False)`,降级告警出现 | | Redis 抛 `ConnectionError` | 退回进程内锁,不抛异常,业务完成 | | FakeGateway 缺方法(`AttributeError`) | 归类 unavailable,测试不红 | | 释放只删自己的锁 | 错误 token 释放返回 False,锁仍在 | | **三处 key 形态全覆盖** | `agg:event:` / `agg:suitability:` / `l3:` 均带 `lock:` 前缀 | **验收**: - [x] `run_locked` 签名与 `fn(locked)` 语义不变,三处调用点**无需修改**(`alert_service.py` / `profile_l3.py` 本次零改动) - [x] 全量 pytest 绿(实测 **510 passed**,用例表 6 条全部落地) - [ ] 停 Redis → 业务仍可完成;恢复 Redis → 行为一致(**需手工,未执行**;`ConnectionError` 路径已由单测覆盖) --- ### T-202 · B2 中间件顺序测试守卫 - [x] 新增守卫测试 `tests/test_audit_middleware.py::test_audit_middleware_runs_inside_trace_middleware`(同时把 `trace_id` 加入 `_http_access` 查询列) ```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 执行,此处会静默为空" ``` **验收**: - [x] 正常顺序下测试通过(pytest 510 绿) - [ ] **临时调换 `main.py` 两个装饰器顺序 → 测试变红**(hy3 日志称已验证变红并恢复;**本次会话未复核**,建议重跑一次确认) - [ ] 恢复顺序 → 转绿 - [x] **不**加 `ensure_trace()` 兜底(未加,顺序错误不会被掩盖) --- ### 第 2 批完成检查 - [x] `python -m pytest -q` 全绿且用例数 ≥ 503(实测 510) - [ ] `POST /api/simulate/trade` 阻断 + 放行各一次 - [ ] 预警聚合仍为"同客户同日仅一张 pending 单" - [ ] 停/启 Redis 各测一次 - [x] 未新增配置项、未新增依赖、未改路由 --- ## 第 3 批 · 合并后(需拍板或涉及 DDL,本期不做) - [ ] T-301 B1 `agent_message` 加 UNIQUE 索引 **+ `insert_turn` 补 `IntegrityError` 重试**(两步必须同时做,只加索引会让并发变 500) - 前置:先查历史重号 `SELECT session_id, seq_no, COUNT(*) FROM agent_message GROUP BY 1,2 HAVING COUNT(*)>1` - 附带:`next_seq_no` + `insert_message` 非事务组合建议废弃 - [ ] T-302 B5 限流 `INCR` 后无条件 `EXPIRE`(去掉"首命中"判断) - [ ] T-303 C1 `core_ro` 配 DB 只读账号(需运维;须拆分连接串) - [ ] T-304 F1 支持 `convert`(需产品三条口径;**记得同步改 `core_ro.py:390`/`:432` 的 `trade_type IN` 过滤**) - [ ] T-305 F2 意图识别升级(建议先扩同义词表 + 多意图,保持可审计性) ## 第 4 批 · 合并后(重构类,本期不做) - [ ] T-401 D1 日志基建(`utils/logger.py` 仅一行 docstring) - [ ] T-402 E1 权限逻辑(**建议维持三层纵深,只补文档不收拢**) - [ ] T-403 E2 Tool 注册表聚合 - [ ] T-404 E3 `model/` 层启用(渐进式加 `TypedDict`) - [ ] T-405 E4 注入词表配置化 - [ ] T-406 F3 R-05 动态评分 - [ ] T-407 B3/B4 双写判据文档化(**判据:新增可丢留痕,变更不可丢**) --- ## 完成判定(总验收) > 状态(**2026-09-10 全部闭环**):代码侧自 `037ce7e` 起已推送远程;**§7.2 七项手工冒烟已补跑 7/7 PASS**;冒烟残留已按 SOP §2 重灌清除,全量 **510 passed** 复绿。 - [x] FR-01~FR-06 六项验收标准逐条通过(FR-01/02 文档勘误与标注、FR-03 无 key 告警、FR-04 审计告警、FR-05 Redis 锁、FR-06 中间件顺序测试均已落地) - [x] `python -m pytest -q` 全绿且用例数 ≥ 503(实测 **510**;冒烟残留曾致 3 条集成前置断言红,重灌后复绿 510) - [x] uvicorn 冒烟:`/health` 正常,无 key 告警按预期出现(**2026-09-10 ✓**) - [x] 交易阻断 + 放行路径各一次,预警聚合行为不变(**2026-09-10 ✓** 证据见 `docs/memory/2026-09-10.md`) - [x] 停/启 Redis 各测一次,业务均可完成(**2026-09-10 ✓** Redis 不可用退回进程内锁、恢复后零降级命中) - [x] 未新增配置项、未新增依赖、未改路由 - [x] **已推送**(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上)——原「commit 但不 push」表述**已作废** - [x] 附加守卫有效性验证:调换 `main.py` 两装饰器 → T-202 守卫变红 → 字节级还原后转绿(**2026-09-10 ✓**)