背景:交接文档的定位是「给 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。
252 lines
13 KiB
Markdown
252 lines
13 KiB
Markdown
# 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 ✓**)
|