2026-09-09 18:10:03 +08:00
|
|
|
|
# TODO · 架构改进与稳定性加固
|
|
|
|
|
|
|
|
|
|
|
|
> **用途**:开发执行清单。**按 T-1xx → T-2xx 顺序做**,每完成一项勾选并跑一次 pytest。
|
|
|
|
|
|
> **上游**:《docs/PRD/PRD-架构改进与稳定性加固.md》(做什么)·《docs/项目框架设计/开发计划-架构改进.md》(怎么做)
|
|
|
|
|
|
> **基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`,**pytest 503 绿**
|
2026-09-10 15:04:13 +08:00
|
|
|
|
> **开工前必读**:项目根《交接文档.md》§C
|
2026-09-09 18:13:54 +08:00
|
|
|
|
> **实施记录(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` 阻断+放行各一次。
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
**状态标记**:`- [ ]` 待办 · `- [x]` 已完成 · `- [/]` 进行中
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 开工前检查(必做)
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 确认当前分支 `risk-control-agent`,HEAD 为 `2d0e2fa`
|
|
|
|
|
|
- [x] 跑一次基线:`python -m pytest -q` → 记录实际数字(应为 503 passed)
|
|
|
|
|
|
- [x] 确认 `locks.py` / `redis_gateway.py` 在目标合并分支**无在途改动**
|
2026-09-10 15:04:13 +08:00
|
|
|
|
- [x] 读《交接文档.md》§C 全文(5 分钟,避免踩已记录的坑)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 第 1 批 · 合并前(极低风险)
|
|
|
|
|
|
|
|
|
|
|
|
### T-101 · A1 注入词表条数修正
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 改 `docs/memory/MEMORY.md` §0:`42 条` → `45 条`
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
**验收**:与 `app/service/input_guard.py:43-94` 逐行计数一致(指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7 = 45)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-102 · A2 风控对话 Tool 数量修正
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 改 `docs/memory/MEMORY.md` 仓库地图:`4 个` → `5 个`
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
**验收**:与 `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 专用表数量修正
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 改 `docs/项目框架设计/表设计/02-mysql-agent专用.sql` 注释:`5 张` → `6 张`
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
**验收**:与实建表一致(`customer_threshold_config` / `customer_notify_log` / `advisor_draft` / `compliance_hit_log` / `analytics_query_log` / `risk_aml_list`)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-104 · A4 标注 scoring.py 为预留桩
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 在《架构设计说明书》§4.3.2 补明:`scoring.py` 的 `recompute_customer_score` 直接 `raise NotImplementedError`,是签名冻结的**预留桩**,非实际评分器;L3 `risk_score` 一期恒 NULL
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
**验收**:读者不会误以为动态评分已生效
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-105 · A5 标注 locks.py 为进程内锁
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 在《架构设计说明书》§4.3.4 补明:`locks.py` 用 `threading.Lock`,是**进程内**锁,多实例部署失效为 TODO
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
**验收**:读者知道多实例场景需先处理此项(本清单 T-201 解决)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-106 · G1/G2/G3 非缺陷设计标注
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [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`)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
**验收**:三项在《架构设计说明书》各有独立条目,含"为什么不是问题"
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-107 · C4 无 key 启动告警
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 在 `app/main.py` lifespan 新增告警(紧跟 `main.py:61-65` 现有 dev secret 告警之后)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
```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`)。**若出现循环导入,改为直接写字面量常量**。
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
**验收**(前两项需手工,未执行):
|
2026-09-09 18:10:03 +08:00
|
|
|
|
- [ ] 清空 `.env` 的 `DEEPSEEK_API_KEY` → 启动日志出现告警,**不阻塞启动**
|
|
|
|
|
|
- [ ] 恢复 key → 无该告警
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 无循环导入(采用函数内延迟 import;全量 pytest 中 `app.main` 被多用例正常导入,无 ImportError)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-108 · 审计降级告警强化(authz)
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 改 `app/utils/authz.py:70-76`,补 `trace_id` 等字段
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
logger.exception(
|
|
|
|
|
|
"authz 审计失败(已降级,拒绝语义不变):code=%s agent=%s actor=%s trace_id=%s",
|
|
|
|
|
|
code, agent_type, actor_id, current_trace(),
|
|
|
|
|
|
)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**验收**:日志含 4 个字段;降级语义不变(仍不阻塞、不抛异常)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-109 · 审计降级告警强化(middleware)
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 改 `app/api/audit_middleware.py:78`,补 `status` / `path` / `request_id`
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
**验收**:日志含上述字段;失败路径仍返回原响应,**行为零变化**(pytest 510 绿,原有审计用例无断言变化)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 第 1 批完成检查
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] `python -m pytest -q` 全绿(实测 **510 passed**)
|
2026-09-10 14:45:55 +08:00
|
|
|
|
- [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` 已还原
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 第 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}` |
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] **T-201.1** `app/service/risk/redis_gateway.py` 新增 `acquire_lock`(`SET key token NX EX ttl`)与 `release_lock`(Lua 脚本,只删自己的锁)—— **纯新增,不动现有方法**
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
```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))
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] **T-201.2** `app/service/risk/locks.py` 改造 `run_locked` 为双层(Redis 为主、进程内为备),新增常量:
|
2026-09-09 18:10:03 +08:00
|
|
|
|
- `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`**)→ 退回进程内锁,绝不向上抛
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] **T-201.3** 新增测试(`tests/test_locks_redis.py`,6 条,无本机 Redis 依赖,全部走 monkeypatch 假网关):
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
| 用例 | 断言 |
|
|
|
|
|
|
| --- | --- |
|
|
|
|
|
|
| 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:` 前缀 |
|
|
|
|
|
|
|
|
|
|
|
|
**验收**:
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] `run_locked` 签名与 `fn(locked)` 语义不变,三处调用点**无需修改**(`alert_service.py` / `profile_l3.py` 本次零改动)
|
|
|
|
|
|
- [x] 全量 pytest 绿(实测 **510 passed**,用例表 6 条全部落地)
|
|
|
|
|
|
- [ ] 停 Redis → 业务仍可完成;恢复 Redis → 行为一致(**需手工,未执行**;`ConnectionError` 路径已由单测覆盖)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### T-202 · B2 中间件顺序测试守卫
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 新增守卫测试 `tests/test_audit_middleware.py::test_audit_middleware_runs_inside_trace_middleware`(同时把 `trace_id` 加入 `_http_access` 查询列)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
```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 执行,此处会静默为空"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**验收**:
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 正常顺序下测试通过(pytest 510 绿)
|
2026-09-09 18:15:08 +08:00
|
|
|
|
- [ ] **临时调换 `main.py` 两个装饰器顺序 → 测试变红**(hy3 日志称已验证变红并恢复;**本次会话未复核**,建议重跑一次确认)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
- [ ] 恢复顺序 → 转绿
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] **不**加 `ensure_trace()` 兜底(未加,顺序错误不会被掩盖)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 第 2 批完成检查
|
|
|
|
|
|
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] `python -m pytest -q` 全绿且用例数 ≥ 503(实测 510)
|
2026-09-09 18:10:03 +08:00
|
|
|
|
- [ ] `POST /api/simulate/trade` 阻断 + 放行各一次
|
|
|
|
|
|
- [ ] 预警聚合仍为"同客户同日仅一张 pending 单"
|
|
|
|
|
|
- [ ] 停/启 Redis 各测一次
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 未新增配置项、未新增依赖、未改路由
|
2026-09-09 18:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 第 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 14:45:55 +08:00
|
|
|
|
> 状态(**2026-09-10 全部闭环**):代码侧自 `037ce7e` 起已推送远程;**§7.2 七项手工冒烟已补跑 7/7 PASS**;冒烟残留已按 SOP §2 重灌清除,全量 **510 passed** 复绿。
|
2026-09-09 18:13:54 +08:00
|
|
|
|
|
|
|
|
|
|
- [x] FR-01~FR-06 六项验收标准逐条通过(FR-01/02 文档勘误与标注、FR-03 无 key 告警、FR-04 审计告警、FR-05 Redis 锁、FR-06 中间件顺序测试均已落地)
|
2026-09-10 14:45:55 +08:00
|
|
|
|
- [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 不可用退回进程内锁、恢复后零降级命中)
|
2026-09-09 18:13:54 +08:00
|
|
|
|
- [x] 未新增配置项、未新增依赖、未改路由
|
2026-09-10 14:45:55 +08:00
|
|
|
|
- [x] **已推送**(远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上)——原「commit 但不 push」表述**已作废**
|
|
|
|
|
|
- [x] 附加守卫有效性验证:调换 `main.py` 两装饰器 → T-202 守卫变红 → 字节级还原后转绿(**2026-09-10 ✓**)
|