diff --git a/app/api/audit_middleware.py b/app/api/audit_middleware.py
index 79336a8..1e876f4 100644
--- a/app/api/audit_middleware.py
+++ b/app/api/audit_middleware.py
@@ -76,5 +76,11 @@ async def audit_middleware(request: Request, call_next) -> Response:
try:
write_http_access(request, status, int((time.perf_counter() - started) * 1000))
except Exception:
- logger.warning("http_access audit failed (degrade, not blocking)", exc_info=True)
+ logger.warning(
+ "http_access audit failed (degrade, not blocking): status=%s path=%s request_id=%s",
+ status,
+ request.url.path,
+ current_request_id(),
+ exc_info=True,
+ )
return response
diff --git a/app/main.py b/app/main.py
index 1aa2cca..f2f0c24 100644
--- a/app/main.py
+++ b/app/main.py
@@ -63,6 +63,13 @@ async def lifespan(_: FastAPI):
"JWT_DEV_SECRET is the public default; issued dev tokens are forgeable "
"(demo/CI only, never expose to untrusted networks)"
)
+ if not settings.deepseek_api_key:
+ from app.service.agent_service import _DEGRADED_PREFIX
+
+ logger.warning(
+ "DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 %s),LLM 能力不可用",
+ _DEGRADED_PREFIX,
+ )
redis_gateway.set_gateway(redis_gateway.RedisGateway())
try:
yield
diff --git a/app/service/risk/locks.py b/app/service/risk/locks.py
index 24fd4c4..6cf748a 100644
--- a/app/service/risk/locks.py
+++ b/app/service/risk/locks.py
@@ -1,19 +1,29 @@
-"""进程内聚合锁原语(B7 · 挂账①:从 alert_service 公共化,profile_l3 同用)。
+"""聚合锁原语(B7 公共化 → T-201 双层:Redis 分布式锁为主,进程内锁为备)。
-单进程内按 key 串行减少并发首单冲突(多进程部署换 Redis SET NX,接口不变);
-拿锁超时降级独立执行,冲突安全由调用方兜底(预警聚合:同日单条锚点查询在
-锁内重查;L3:乐观锁重试),宁多勿漏。
+设计(开发计划 §4.1.3,经两轮独立审核冻结):
+- run_locked(key, fn):Redis 抢到 → fn(locked=True),finally 释放;
+ Redis 等待超时 → fn(locked=False)(与原语义一致,不可改抛异常);
+ Redis 不可用(任何异常,含测试 Fake 缺方法的 AttributeError)→ 退回进程内锁。
+- 进程内锁保留原 `threading.Lock` 逻辑:Redis 挂掉时最坏退回「当前单进程行为」,不会更差。
+- 三处调用点(alert_service ×2 / profile_l3 ×1)签名与调用方式不变,本文件改完它们一行都不用动。
"""
from __future__ import annotations
import logging
import threading
+import time
from typing import Any, Callable
+from uuid import uuid4
+
+from app.service.risk import redis_gateway
logger = logging.getLogger(__name__)
-LOCK_TIMEOUT_SECONDS = 2.0
+LOCK_TIMEOUT_SECONDS = 2.0 # 抢锁等待上限(保持原值、原降级语义)
+LOCK_TTL_SECONDS = 30 # 锁持有上限(防进程崩溃后死锁;二审由 10 上调,理由见开发计划 §4.1.5)
+_LOCK_KEY_PREFIX = "lock:" # 命名空间隔离,避免误删 sess:/ratelimit:/auth: 等业务 key
+_RETRY_INTERVAL = 0.05 # 抢锁轮询间隔(2 秒窗口约 40 次尝试)
_locks: dict[str, threading.Lock] = {}
_locks_guard = threading.Lock()
@@ -28,11 +38,53 @@ def lock_for(key: str) -> threading.Lock:
return lock
+def _acquire_redis(redis_key: str, token: str) -> str:
+ """抢 Redis 锁,直到 LOCK_TIMEOUT_SECONDS 耗尽。
+
+ 返回 'acquired'(拿到)/ 'timeout'(等待超时)/ 'unavailable'(Redis 不可用)。
+ **任何异常(ConnectionError、Fake 缺方法的 AttributeError 等)一律归为 unavailable**,
+ 绝不向上抛——保证 Redis 故障时不阻塞、不报错,安全退回进程内锁。
+ """
+ deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
+ while True:
+ try:
+ if redis_gateway.get_gateway().acquire_lock(redis_key, token, LOCK_TTL_SECONDS):
+ return "acquired"
+ except Exception:
+ logger.warning("Redis 锁不可用,退回进程内锁:%s", redis_key, exc_info=True)
+ return "unavailable"
+ if time.monotonic() >= deadline:
+ return "timeout"
+ time.sleep(_RETRY_INTERVAL)
+
+
+def _release_redis(redis_key: str, token: str) -> None:
+ """释放 Redis 锁(Lua 只删自己的锁);释放失败仅日志,由 TTL 兜底过期。"""
+ try:
+ redis_gateway.get_gateway().release_lock(redis_key, token)
+ except Exception:
+ logger.warning("Redis 锁释放失败(已忽略,TTL 兜底过期):%s", redis_key, exc_info=True)
+
+
def run_locked(key: str, fn: Callable[[bool], Any]) -> Any:
- """锁内执行 fn(locked=True);获取超时降级 fn(locked=False)。"""
+ """锁内执行 fn(locked=True);Redis 不可用时退回进程内锁;超时降级 fn(locked=False)。"""
+ token = uuid4().hex
+ outcome = _acquire_redis(_LOCK_KEY_PREFIX + key, token)
+
+ 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("agg lock timeout, run without lock (conflicts bounded by caller): %s", key)
+ logger.warning("进程内锁超时,降级执行(冲突由调用方兜底):%s", key)
return fn(locked=False)
try:
return fn(locked=True)
diff --git a/app/service/risk/redis_gateway.py b/app/service/risk/redis_gateway.py
index 91f1bed..bf919bf 100644
--- a/app/service/risk/redis_gateway.py
+++ b/app/service/risk/redis_gateway.py
@@ -60,6 +60,23 @@ class RedisGateway:
def expire(self, key: str, ttl_seconds: int) -> None:
self._ensure().expire(key, ttl_seconds)
+ # ---- 聚合分布式锁(T-201 · SET NX EX + Lua 释放)----
+
+ 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 原子释放:仅删自己持有的锁,防误删他人锁(GET/DEL 两步非原子会误删)。"""
+ 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))
+
_gateway: RedisGateway | Any | None = None
diff --git a/app/utils/authz.py b/app/utils/authz.py
index f6f30e4..c8fbd1b 100644
--- a/app/utils/authz.py
+++ b/app/utils/authz.py
@@ -69,8 +69,9 @@ def record_authz_denial(
)
except Exception:
logger.exception(
- "authz audit failed (degraded): code=%s agent=%s actor=%s",
+ "authz 审计失败(已降级,拒绝语义不变):code=%s agent=%s actor=%s trace_id=%s",
code,
agent_type,
actor_id,
+ current_trace(),
)
diff --git a/docs/PRD/PRD-架构改进与稳定性加固.md b/docs/PRD/PRD-架构改进与稳定性加固.md
new file mode 100644
index 0000000..42d8bf5
--- /dev/null
+++ b/docs/PRD/PRD-架构改进与稳定性加固.md
@@ -0,0 +1,210 @@
+# PRD · 架构改进与稳定性加固
+
+> **版本**:v1.0(2026-09-09)
+> **状态**:待评审 → 通过后进入开发
+> **代码基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`,pytest 503 绿
+> **关联文档**:
+> - 架构现状剖析:《docs/项目框架设计/架构设计说明书.md》
+> - 问题清单与方案对比:《docs/项目框架设计/改进方案评审-问题清单与对比.md》(已过第一轮审核)
+> - 开发计划(HOW):《docs/项目框架设计/开发计划-架构改进.md》(已过第二轮审核)
+> - 任务清单:《docs/项目框架设计/TODO-架构改进.md》
+> - 开工入口:《docs/交接文档-架构改进.md》
+>
+> **本文定位**:只定义**做什么(WHAT)与为什么(WHY)及验收标准**;**怎么做(HOW)见开发计划**。
+
+---
+
+## 1. 背景与目标
+
+### 1.1 背景
+
+2026-09-09 对 `risk-control-agent` 分支做了全量架构梳理,产出《架构设计说明书》,并在其中登记了 **24 项**待处理项(11 项改进空间 + 5 处文档口径不符 + 3 项非缺陷标注 + 5 项审核补充)。
+
+经两轮独立 AI 审核(第一轮审问题清单、第二轮审开发计划),**六项核心事实断言全部属实、无阻断级错误**,设计方案核验成立。
+
+用户随后拍板:**补告警 + 换 Redis 分布式锁**,理由是"马上要合并代码了"。
+
+### 1.2 目标
+
+| 目标 | 衡量方式 |
+| --- | --- |
+| **G1** 消除多实例部署下的并发保护失效 | Redis 锁生效;单进程行为不劣化 |
+| **G2** 消除"静默失败"——出了事没人知道 | 无 key、审计降级两处均有明确告警 |
+| **G3** 消除文档与代码事实不符 | 5 处口径修正完成,核对一致 |
+| **G4** 关键设计决策可追溯、不被后人误改 | 3 项"非缺陷"设计写入架构文档 |
+| **G5** 中间件顺序错误可被测试拦截 | 顺序被调换时测试变红 |
+
+### 1.3 非目标(本期不做)
+
+- 不实现 R-05 动态评分(`scoring.py` 桩保留)
+- 不支持 `convert` 交易类型(接口协议变更,待产品确认)
+- 不做日志基建(格式化/落盘/全量接 trace_id)
+- 不收拢权限逻辑、不启用 `model/` 层、不做 Tool 注册表合并
+- 不改变审计 fail-open 策略(保持现状 + 补告警)
+
+---
+
+## 2. 范围
+
+### 2.1 本期范围(合并前实施,11 个原子项 → 6 个需求)
+
+| 需求 ID | 名称 | 原子项 | 批次 |
+| --- | --- | --- | --- |
+| FR-01 | 文档口径修正 | A1~A5 | 第 1 批 |
+| FR-02 | 非缺陷设计标注 | G1~G3 | 第 1 批 |
+| FR-03 | 无 key 启动告警 | C4 | 第 1 批 |
+| FR-04 | 审计降级告警强化 | C3 相关 | 第 1 批 |
+| FR-05 | Redis 分布式锁 | B6 | 第 2 批 |
+| FR-06 | 中间件顺序测试守卫 | B2 | 第 2 批 |
+
+### 2.2 后续范围(合并后,本期不实施)
+
+第 3 批(需拍板或 DDL):B1 唯一索引+重试、B5 限流 EXPIRE、C1 只读账号、F1 convert、F2 意图识别
+第 4 批(重构类):D1 日志基建、E1~E4 可维护性、F3 动态评分、B3/B4 判据固化
+
+详细设计见《开发计划-架构改进.md》§5、§6。
+
+---
+
+## 3. 需求清单
+
+### FR-01 文档口径修正
+
+**描述**:修正 5 处"文档说法与代码实测不符",避免后续读者按错误数字核对。
+
+**动机**:这些数字会被当作事实依据做决策(如"注入词表 42 条"影响安全评估、"Tool 4 个"影响能力盘点)。错了会引发误判。
+
+**明细与验收标准**:
+
+| 原子项 | 位置 | 现状 → 应为 | 验收 |
+| --- | --- | --- | --- |
+| A1 | `docs/memory/MEMORY.md` §0 | 42 条 → **45 条** | 与 `input_guard.py:43-94` 逐行计数一致 |
+| A2 | `docs/memory/MEMORY.md` 仓库地图 | 4 个 → **5 个** | 与 `chat_tools.py:328` 注册表一致 |
+| A3 | `docs/项目框架设计/表设计/02-mysql-agent专用.sql` 注释 | 5 张 → **6 张** | 与实建表一致(含 `risk_aml_list`) |
+| A4 | 《架构设计说明书》§4.3.2 | 补明 `scoring.py` 为 `NotImplementedError` 桩 | 读者不会误以为评分已生效 |
+| A5 | 《架构设计说明书》§4.3.4 | 补明 `locks.py` 为**进程内**锁 | 读者知道多实例会失效 |
+
+### FR-02 非缺陷设计标注
+
+**描述**:把 3 项"看起来像问题、实际设计正确"的决策写入架构文档。
+
+**动机**:这三项每次评审都会被重新提出,消耗沟通成本。写明后可直接引用。
+
+| 原子项 | 要写清什么 |
+| --- | --- |
+| G1 `X-Trace-Id` 可伪造 | 有白名单防响应头注入;**身份只认 JWT,trace_id 绝不用于权限判定** |
+| G2 `trace_id` 概率唯一 | 64 bit 随机,请求级;约 2^32 次请求才 50% 碰撞,无需数据库唯一校验 |
+| G3 `admin.py` / `knowledge.py` 空壳 | T-21 拍板一期只做脚本入库;未挂载路由;**空 ≠ RAG 缺失**(能力在 `service/rag_service.py`) |
+
+**验收**:三项均在《架构设计说明书》中有独立条目,含"为什么不是问题"。
+
+### FR-03 无 DeepSeek key 启动告警
+
+**描述**:启动时若 `DEEPSEEK_API_KEY` 未配置,打 WARNING 日志。
+
+**动机**:当前无 key 时静默走 `_degraded_reply`,生产漏配会"看起来正常",用户和运维都察觉不到。
+
+**验收标准**:
+1. 清空 `.env` 的 `DEEPSEEK_API_KEY` → 启动日志出现降级告警,且**不阻塞启动**
+2. 恢复 key → 无该告警
+3. 告警位置与 `main.py:61-65` 现有 dev secret 告警一致(沿用同模式)
+4. 不产生循环导入(若引入,改用字面量常量)
+
+### FR-04 审计降级告警强化
+
+**描述**:两处审计失败日志补充 `trace_id` 与关键上下文字段。
+
+**动机**:当前 `utils/authz.py:70-76` 与 `audit_middleware.py:78` 已打日志,但**不含 trace_id**——审计失败时连是哪次请求都不知道,无法追溯。
+
+**验收标准**:
+1. `utils/authz.py` 审计失败日志含 `code` / `agent` / `actor` / `trace_id`
+2. `audit_middleware.py` 审计失败日志含 `status` / `path` / `request_id`
+3. 日志级别与降级语义不变(仍不阻塞业务)
+4. 失败路径仍返回原响应(行为零变化)
+
+### FR-05 Redis 分布式锁
+
+**描述**:把 `locks.py` 的进程内锁改造为「Redis 为主、进程内为备」的双层结构。
+
+**动机**:`threading.Lock` 跨进程不可见,多实例部署时同一客户并发请求会同时进入临界区。用户明确要求,因"马上合并代码"(合并后可能多实例部署)。
+
+**验收标准**:
+
+| # | 标准 |
+| --- | --- |
+| 1 | `run_locked(key, fn)` 签名与 `fn(locked)` 回调语义**完全不变**,三处调用点无需修改 |
+| 2 | Redis 可用时跨进程互斥生效(Redis 侧 key 带 TTL) |
+| 3 | Redis 不可用(含 `ConnectionError`、Fake 缺方法的 `AttributeError`)→ **退回进程内锁**,不抛异常、业务照常完成 |
+| 4 | Redis 可用但等待超时 → `fn(locked=False)` 降级执行,与原语义一致 |
+| 5 | 释放只删自己持有的锁(错误 token 释放返回 False,锁仍在) |
+| 6 | **三处调用点全覆盖**:`agg:event:` / `agg:suitability:` / `l3:`(不得只改两处) |
+| 7 | 全量 pytest **503 仍绿**(新增锁测试计入总数) |
+| 8 | 三处调用点的 key 均带 `lock:` 前缀,与既有 `sess:`/`ratelimit:`/`auth:` 命名空间隔离 |
+
+**关键约束**:测试环境**不依赖本机 Redis**(`redis_gateway` 一律 fake 注入),新代码不得假设 Redis 可用。
+
+### FR-06 中间件顺序测试守卫
+
+**描述**:新增测试,断言 `audit_log` 中 `trace_id` 非空。
+
+**动机**:`main.py` 中 audit 先注册(81 行)、trace 后注册(87 行),依赖 Starlette 的 `insert(0)` + `reversed` 语义保证 trace 在外层先执行。**调换两个装饰器会使 audit 先执行、`current_trace()` 返回空串,全站审计静默丢失 trace_id,且不报错**。
+
+**验收标准**:
+1. 新增测试在正常顺序下通过
+2. **临时调换装饰器顺序 → 测试变红**(确认测试确实有效)
+3. 恢复顺序 → 测试转绿
+4. 不放 `ensure_trace()` 兜底(会掩盖顺序错误,导致 trace_id 与响应头不一致)
+
+---
+
+## 4. 非功能需求
+
+| 类别 | 要求 |
+| --- | --- |
+| 兼容性 | 不破坏任何现有 API 契约;不改路由;不新增配置项(若配置化 `LOCK_TTL_SECONDS` 需同步 `.env.example`) |
+| 依赖 | 不新增第三方依赖(`redis` 库已在 `requirements.txt`) |
+| 测试 | 全量 pytest 保持 503 绿,新增测试随改动交付 |
+| 可回滚 | 每个需求独立可回滚;FR-05 退回进程内锁即等价原行为 |
+
+---
+
+## 5. 约束与红线
+
+**项目五条红线(任何改动不得触碰)**:
+1. Core 表只读
+2. 审计表只 INSERT
+3. 不自动冻结
+4. 不自动改风险等级
+5. 仅 R-02 可阻断交易
+
+**技术约束**:
+- Windows 原生部署,系统 Python 3.13.14
+- 测试不依赖本机 Redis
+- 改路由须同步 `tests/test_main.py::test_all_routers_mounted` 路径清单
+- 新增依赖须用户确认
+
+---
+
+## 6. 风险
+
+| 风险 | 影响 | 应对 |
+| --- | --- | --- |
+| FR-05 引入 TTL 新失败模式:临界区超 TTL 导致并发进入 | 中(最坏重复 pending 单,非数据损坏) | TTL 取 30 秒;L3 乐观锁 + 锁内重查兜底;取得 p99 后按 `max(30, p99×5)` 调整 |
+| 测试期 Redis 不可用产生大量日志噪声 | 低 | 被捕获不导致失败;对比基线确认不漂移 |
+| 合并冲突(`locks.py`/`redis_gateway.py` 有在途改动) | 中 | 合并前确认目标分支无在途改动 |
+| FR-03 引入循环导入 | 低 | 若出现,改用字面量常量 |
+
+---
+
+## 7. 验收总纲
+
+本次改进**全部完成**的判定:
+
+- [ ] FR-01~FR-06 六项验收标准逐条通过
+- [ ] `python -m pytest -q` 全绿且用例数 ≥ 503(新增测试计入)
+- [ ] uvicorn 启动冒烟:`/health` 正常,无 key 告警按预期出现
+- [ ] `POST /api/simulate/trade` 阻断 + 放行路径各一次,预警聚合仍为"同客户同日仅一张 pending 单"
+- [ ] 停 Redis → 业务仍可完成;恢复 Redis → 行为一致
+- [ ] 未新增配置项、未新增依赖、未改路由
+
+**交付纪律**:commit 但不 push,等用户在浏览器目视确认后再推送。
diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md
index 5eefa6e..5161778 100644
--- a/docs/memory/MEMORY.md
+++ b/docs/memory/MEMORY.md
@@ -9,7 +9,7 @@
**项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。
-**当前进度:** 需求与表设计已定 · **风控模块 B1~B9b 全部完成(M2 tag risk-m2),M4 复核已闭环(2026-09-07),风控阶段 B 正式完结** · **Wave 0 已完成(2026-09-07,经独立 AI 评审闭环)**:T-01 JWT 鉴权(auth_service Auth SDK + deps 工厂替换 + X-Agent-Type 准入矩阵)/ T-02 审计中间件(http_access + 独立 request_id + 4xx/500 统一错误体 + input_guard_log 双写)/ T-06 chat 最小闭环(POST /api/chat + 会话落库 + Redis 窗口)/ T-07 LangGraph StateGraph 骨架 + DeepSeek(无 key 降级)· **T-04 Core RO Tool 节点已完成(2026-09-07)**:app/tool/core_tools.py 三只读 Tool + tool_service(意图/归属校验/run_tool)+ 图 tool 节点 + agent_tool_call 落库 + `utils/authz.py` 公共鉴权留痕,**321 测试绿(首评+复审双闭环)**。风控阶段 C 已完成(2026-09-07,345 绿,tag `risk-m3`,A-6 对话线验收通过,独立 AI 评审 PASS P0=0)。T-03 输入防护已完成(2026-09-07,378 绿,独立 AI 评审 PASS with findings P0=0):app/service/input_guard.py 注入词表 42 条纯函数检测 + oversize 4000 + actor 级 Redis 固定窗口限流 30 次/分(fail-open);chat 链路顺序 = 鉴权→准入→空白→限流 429→注入/超长 400→归属→会话,被拒 fail-fast 不建会话,blocked 落 input_guard_log(ENUM 四值已用满)。**阶段一「对齐 main 基准」AL-01~AL-08 已完成(2026-09-07,逐项独立 commit bb244f4~b5fd52e):适当性判定换核为 main 的 core_ro.check_suitability(C×R 矩阵表数据驱动,match_result 五值/JR-AST-012/FM-01/FM-03/JR-AST-PRO 契约,SUIT-001~008 退役);risk_suitability_log 重建 21 列;Core 表加 is_hnw/风评七新列(expires_at);种子 33 客户/14 产品;全量 406 passed 0 failed 0 skipped + uvicorn 冒烟三端点通过;risk-m1 已补打(指向 3c07de6)**。**下一步:阶段一验收门(**合并 main 前的最终交付检查点,非分支开发阻塞**;用户浏览器目视确认 UI)→ AL-09 合并 main → AL-10 PRD v1.2 → AL-11 docx 登记 → 阶段二 C4~C6(**已完成并打 `risk-m4` tag**)/ 前端 React 多 Agent 入口(HashRouter `web/` init)。****开发在分支 `risk-control-agent`(与 origin/main 已分叉:领先 84 提交 / 落后 0,origin/main 为分支祖先;**分支已推送远程,origin/risk-control-agent 同步于 `1d00e53`,本地跟踪已建立**;旧文档中的 `feature/risk` 为过时口径)。**
+**当前进度:** 需求与表设计已定 · **风控模块 B1~B9b 全部完成(M2 tag risk-m2),M4 复核已闭环(2026-09-07),风控阶段 B 正式完结** · **Wave 0 已完成(2026-09-07,经独立 AI 评审闭环)**:T-01 JWT 鉴权(auth_service Auth SDK + deps 工厂替换 + X-Agent-Type 准入矩阵)/ T-02 审计中间件(http_access + 独立 request_id + 4xx/500 统一错误体 + input_guard_log 双写)/ T-06 chat 最小闭环(POST /api/chat + 会话落库 + Redis 窗口)/ T-07 LangGraph StateGraph 骨架 + DeepSeek(无 key 降级)· **T-04 Core RO Tool 节点已完成(2026-09-07)**:app/tool/core_tools.py 三只读 Tool + tool_service(意图/归属校验/run_tool)+ 图 tool 节点 + agent_tool_call 落库 + `utils/authz.py` 公共鉴权留痕,**321 测试绿(首评+复审双闭环)**。风控阶段 C 已完成(2026-09-07,345 绿,tag `risk-m3`,A-6 对话线验收通过,独立 AI 评审 PASS P0=0)。T-03 输入防护已完成(2026-09-07,378 绿,独立 AI 评审 PASS with findings P0=0):app/service/input_guard.py 注入词表 45 条纯函数检测 + oversize 4000 + actor 级 Redis 固定窗口限流 30 次/分(fail-open);chat 链路顺序 = 鉴权→准入→空白→限流 429→注入/超长 400→归属→会话,被拒 fail-fast 不建会话,blocked 落 input_guard_log(ENUM 四值已用满)。**阶段一「对齐 main 基准」AL-01~AL-08 已完成(2026-09-07,逐项独立 commit bb244f4~b5fd52e):适当性判定换核为 main 的 core_ro.check_suitability(C×R 矩阵表数据驱动,match_result 五值/JR-AST-012/FM-01/FM-03/JR-AST-PRO 契约,SUIT-001~008 退役);risk_suitability_log 重建 21 列;Core 表加 is_hnw/风评七新列(expires_at);种子 33 客户/14 产品;全量 406 passed 0 failed 0 skipped + uvicorn 冒烟三端点通过;risk-m1 已补打(指向 3c07de6)**。**下一步:阶段一验收门(**合并 main 前的最终交付检查点,非分支开发阻塞**;用户浏览器目视确认 UI)→ AL-09 合并 main → AL-10 PRD v1.2 → AL-11 docx 登记 → 阶段二 C4~C6(**已完成并打 `risk-m4` tag**)/ 前端 React 多 Agent 入口(HashRouter `web/` init)。****开发在分支 `risk-control-agent`(与 origin/main 已分叉:领先 84 提交 / 落后 0,origin/main 为分支祖先;**分支已推送远程,origin/risk-control-agent 同步于 `1d00e53`,本地跟踪已建立**;旧文档中的 `feature/risk` 为过时口径)。**
**仓库地图:**
@@ -24,7 +24,7 @@
| `app/service/auth_service.py` | **已实现(T-01)** | Auth SDK:JWT 验签(HS256 dev/RS256 生产)、必填 claims、jti 吊销(Redis fail-open);签发 CLI `scripts/dev/issue_dev_token.py` |
| `app/service/risk/*` + `service/suitability.py` | **已实现** | RISK-001~005 规则 / 预警聚合 / L3 写入 / AML / 引擎编排 / suitability(**阶段一换核:内核改调 core_ro.check_suitability,SUIT-001~008 已退役**)/ locks+redis_gateway 公共原语(B7) |
| `app/service/agent_service.py` `memory_service.py` | **已实现(T-07/T-06/T-04/C2 + 方案 C)** | LangGraph StateGraph(tool→llm→guard)+ DeepSeek(无 key 降级);T-04 tool 节点(关键词意图 customer/advisor/risk 分组 + tool_service.run_tool);C2 tool_node 守卫放宽(requires_customer=False 允许无绑定客户,支撑 A-6 全量待审);Redis 会话窗口 + MySQL 回源。**方案 C:`stream_chat` 生成器(Tool 同步跑完→逐块推 LLM 文本)+ `needs_disclaimer`(首帧 meta 与落库尾部共用口径)** |
-| `app/service/tool_service.py` `app/tool/core_tools.py` `service/risk/chat_tools.py` | **已实现(T-04/C1)** | 对话 Tool 编排(统一注册表 get_registered_tool:core+risk / 归属校验 / agent_tool_call 落库)+ Core RO 三只读 Tool(L0/持仓/流水)+ C1 风控四只读 Tool(alert_query/customer_context/suitability_check/aml_lookup,RISK_TOOL_REGISTRY) |
+| `app/service/tool_service.py` `app/tool/core_tools.py` `service/risk/chat_tools.py` | **已实现(T-04/C1)** | 对话 Tool 编排(统一注册表 get_registered_tool:core+risk / 归属校验 / agent_tool_call 落库)+ Core RO 三只读 Tool(L0/持仓/流水)+ C1 风控五只读 Tool(query_overdue_alerts/alert_query/customer_context/suitability_check/aml_lookup,RISK_TOOL_REGISTRY) |
| `app/repository/session_repository.py` | **已实现(T-06/T-04 + 前端接入 B/C)** | agent_session / agent_message / agent_tool_call 读写;**方案 B 增 `list_sessions`(分页+total)/ `list_messages_page`(seq 升序分页,勿与 LLM 窗口的 `list_messages` 混用)/ `close_session`(条件更新防并发)**;**方案 C 增 `insert_turn`(user+assistant 同事务落库 + 事务内取 seq,修评审 P0/P1)** |
| `app/gateway/` | **已实现** | 模拟交易网关(仅 gateway_repository 可 INSERT core_trade,B5) |
| `app/repository/core_ro.py` | **已实现** | Core 只读 SELECT(含风控扩展 sum_trades_on_date / list_trades_range / list_active_customers);**阶段一吸收 main:check_suitability(C×R 矩阵判定,CURDATE() 改 Python 端 `_is_expired`)/ list_products_for_customer / list_holdings 合并(limit=500+新列)/ list_trades / get_customer_l0 扩列版** |
diff --git a/docs/交接文档-架构改进.md b/docs/交接文档-架构改进.md
new file mode 100644
index 0000000..c019e48
--- /dev/null
+++ b/docs/交接文档-架构改进.md
@@ -0,0 +1,241 @@
+# 交接文档 · 架构改进与稳定性加固(给执行 AI)
+
+> **版本**:2026-09-09 v1.0
+> **用途**:**给执行代码改动的 AI(hy3)**。读完本文即可开工,**不需要重读全部代码**。
+> **代码基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`,pytest **503 绿**
+> **本轮已完成的规划**:PRD + 架构设计说明书 + 开发计划 + TODO + 两轮独立审核,**全部已通过审核**。
+> **你的任务**:按 TODO 执行代码改动。**只做清单内的事,不做范围外改动。**
+
+---
+
+## 0. 一句话任务
+
+改 11 个原子项:9 项是文档修正和加告警(低风险),2 项是**把进程内锁换成 Redis 分布式锁** + 加一个测试守卫。改完 pytest 必须仍全绿。
+
+---
+
+## 1. 五分钟背景
+
+### 1.1 项目是什么
+
+XingHuo 智能财富管家:金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座,**四 Agent 不互调 LLM**。
+
+技术栈:Python 3.13 + FastAPI + LangGraph + MySQL 双库(`jinrong_core` 只读模拟 / `jinrong_agent` 业务)+ Redis + Milvus + Neo4j。
+
+风控模块已完整交付(503 测试绿),**马上要合并进 main 分支**——这是本次改进的时间背景。
+
+### 1.2 本次改进的来源
+
+2026-09-09 做了全量架构梳理,发现 24 项待处理项。经两轮独立 AI 审核(第一轮审问题清单、第二轮审开发计划),**无阻断级错误,方案核验成立**。
+
+用户拍板:补告警 + 换 Redis 分布式锁。
+
+### 1.3 为什么要分批
+
+因为"马上合并代码":**合并前只做低风险的 9+2 项**,DDL 类(唯一索引)和重构类(日志基建、权限收拢)一律放合并后。
+
+---
+
+## 2. 你要做的事(范围,严格按此执行)
+
+| 批次 | 原子项 | 内容 | 风险 |
+| --- | --- | --- | --- |
+| **第 1 批** | A1~A5 | 修 5 处文档口径错误 | 极低(纯文档) |
+| | G1~G3 | 补 3 项"非缺陷"设计标注 | 极低(纯文档) |
+| | C4 | 无 key 启动告警(加一行日志) | 低 |
+| | C3 相关 | 审计降级日志补 trace_id(2 处) | 低 |
+| **第 2 批** | **B6** | **Redis 分布式锁(核心改动)** | 中 |
+| | B2 | 中间件顺序测试守卫(新增测试) | 低 |
+
+**详细验收标准**:《docs/项目框架设计/TODO-架构改进.md》(逐项可勾选,照着做即可)
+
+**不要做的**(第 3、4 批,合并后再说):
+
+- ❌ 加 `agent_message` 唯一索引(DDL,需先清洗历史数据)
+- ❌ 限流 `EXPIRE` 改造
+- ❌ `core_ro` DB 只读账号(需运维)
+- ❌ 支持 `convert` 交易(接口协议变更,待产品确认)
+- ❌ 日志基建、权限收拢、model 层启用、注册表合并
+- ❌ 意图识别升级、R-05 动态评分
+
+---
+
+## 3. 必读文档(按优先级)
+
+| 顺序 | 文档 | 看什么 |
+| --- | --- | --- |
+| **1** | 本文(交接文档) | 全局、坑、禁止事项 |
+| **2** | `docs/项目框架设计/TODO-架构改进.md` | **执行清单,照着勾选** |
+| **3** | `docs/PRD/PRD-架构改进与稳定性加固.md` | 做什么、为什么、验收标准 |
+| **4** | `docs/项目框架设计/开发计划-架构改进.md` | **怎么做**(代码设计、降级策略、风险) |
+| 5 | `docs/项目框架设计/架构设计说明书.md` | 架构现状(必要时查) |
+| 6 | `docs/项目框架设计/改进方案评审-问题清单与对比.md` | 24 项问题全景(必要时查) |
+
+---
+
+## 4. 已冻结的设计决策(**不能改**)
+
+这些经过两轮审核,**改了会破坏已验证的方案**:
+
+### 4.1 Redis 锁:双层降级,三条规则不可改
+
+```
+run_locked(key, fn)
+ ├─ Redis 抢到锁 → fn(locked=True),finally 释放
+ ├─ Redis 等待超时 → fn(locked=False) ← 与原语义一致,不可改成抛异常
+ └─ Redis 不可用(任何异常)→ 退回进程内锁 ← 不可改成直接降级
+```
+
+**"任何异常"包括:**
+- `ConnectionError`(Redis 没起)
+- **`AttributeError`(测试 FakeGateway 没有锁方法)** ← 关键,见 §5.2
+
+### 4.2 参数(已定,勿随意改)
+
+| 参数 | 值 | 说明 |
+| --- | --- | --- |
+| `LOCK_TIMEOUT_SECONDS` | **2.0** | 保持原值,不改 |
+| `LOCK_TTL_SECONDS` | **30** | 二审从 10 上调(10 秒无实测支撑) |
+| `_LOCK_KEY_PREFIX` | `lock:` | 与 `sess:`/`ratelimit:`/`auth:` 隔离 |
+| `_RETRY_INTERVAL` | 0.05 | 抢锁重试间隔 |
+
+### 4.3 契约不变
+
+`run_locked(key, fn)` 签名与 `fn(locked)` 回调语义**完全不变**,三处调用点**一行都不用改**。
+
+### 4.4 不放 `ensure_trace()` 兜底
+
+B2 测试守卫要能"真的报警"。若加 `ensure_trace()` 兜底,顺序错了会被掩盖,trace_id 与响应头不一致,更难查。
+
+---
+
+## 5. 关键代码位置与坑
+
+### 5.1 `run_locked` 调用点是**三处**(极易漏)
+
+| # | 位置 | 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 是全系统唯一能阻断交易的规则**。漏改会让最关键路径失去并发保护。
+
+### 5.2 测试**不依赖本机 Redis**
+
+`tests/test_integration_risk.py:6` 明确写"redis_gateway fake(不依赖本机 Redis)"。多个测试用 `monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway())` 注入假网关。
+
+**已核查**:所有 FakeGateway(在 `test_main.py` / `test_audit_middleware.py` / `test_auth_jwt.py` / `test_chat.py` / `test_profile_l3.py` / `test_risk_api.py` 等)**均无 `acquire_lock` / `release_lock`,也无 `__getattr__`**。
+
+→ 调用会抛 `AttributeError` → 你的代码必须归为"Redis 不可用"→ 退回进程内锁。**这是设计,不是 bug。**
+
+### 5.3 Redis 网关现状
+
+`app/service/risk/redis_gateway.py`:单例 `_gateway`,`set_gateway` 注入点,`get_gateway` 惰性创建。已有 `publish`/`delete`/`exists`/`set_ex`/`incr`/`rpush`/`lrange`/`ltrim`/`expire`。**你要新增 `acquire_lock` / `release_lock`,纯新增不动现有方法。**
+
+### 5.4 中间件顺序(B2 相关)
+
+`main.py` audit 先注册(81 行)、trace 后注册(87 行)。Starlette `add_middleware` 用 `insert(0)`、`build_middleware_stack` 用 `reversed` → **数组越靠前越外层 → trace 在外层先执行**。
+
+调换两个装饰器 → audit 先跑 → `current_trace()` 空 → **全站审计静默丢 trace_id,不报错**。这就是要加测试守卫的原因。
+
+### 5.5 TTL 引入的新失败模式(已知,接受)
+
+进程内锁拿到后**永不过期**;Redis 锁有 TTL,若临界区耗时超 30 秒会自动解锁,另一实例可抢到。
+
+**这是 TTL 的固有代价**,兜底是:L3 有数据库乐观锁、预警聚合有"锁内重查锚点",最坏是重复 pending 单,**非数据损坏**。已写入风险表,不要试图消除它(消除就会变成"进程崩溃后死锁")。
+
+---
+
+## 6. 实施顺序
+
+```text
+1. 跑基线 python -m pytest -q 记录数字(应 503)
+2. 第 1 批 T-101 → T-109(文档 + 2 处告警)
+3. 跑测试 确认仍 503 绿
+4. 第 2 批 T-201(Redis 锁)→ T-202(测试守卫)
+5. 跑测试 确认仍 503 绿,新增测试已计入
+6. 手工冒烟 见 §7
+7. commit **不 push**
+```
+
+**建议**:T-201 单独一个 commit,T-202 单独一个 commit,便于回滚。
+
+---
+
+## 7. 验收方式
+
+### 7.1 自动化
+
+```bash
+python -m pytest -q # 必须全绿,用例数 ≥ 503
+```
+
+### 7.2 手工冒烟
+
+| # | 操作 | 期望 |
+| --- | --- | --- |
+| 1 | 清空 `.env` 的 `DEEPSEEK_API_KEY` → 启动 | 日志出现降级告警,**不阻塞启动** |
+| 2 | 恢复 key → 启动 | 无该告警 |
+| 3 | `POST /api/simulate/trade` 阻断 + 放行各一次 | 行为与改动前一致 |
+| 4 | 检查预警聚合 | 仍"同客户同日仅一张 pending 单" |
+| 5 | **停 Redis** → 跑一次交易 | 业务仍可完成(退回进程内锁) |
+| 6 | 恢复 Redis → 再跑一次 | 行为与停服前一致 |
+| 7 | 调换 `main.py` 两个装饰器 → 跑 T-202 测试 | **测试变红**(证明守卫有效)→ 恢复 |
+
+### 7.3 完成判定
+
+- [ ] FR-01~FR-06 验收标准逐条通过(见 PRD §3)
+- [ ] pytest 全绿且 ≥ 503
+- [ ] 未新增配置项、未新增依赖、未改路由
+- [ ] **commit 但不 push**,等用户在浏览器目视确认
+
+---
+
+## 8. 禁止事项
+
+### 8.1 项目五条红线(碰了就是事故)
+
+1. Core 表只读
+2. 审计表只 INSERT
+3. 不自动冻结
+4. 不自动改风险等级
+5. **仅 R-02 可阻断交易**
+
+### 8.2 本次改动纪律
+
+- ❌ 不做清单外的改动(看到别的问题**记下来**,不要顺手改)
+- ❌ 不改 `run_locked` 对外签名
+- ❌ 不改三处调用点的调用方式
+- ❌ 不新增第三方依赖(`redis` 库已在 `requirements.txt`)
+- ❌ 不新增配置项(若决定配置化 `LOCK_TTL_SECONDS`,须同步 `.env.example`)
+- ❌ 不改路由(若改,必须同步 `tests/test_main.py::test_all_routers_mounted` 路径清单)
+- ❌ **不 push**(commit 即可,等用户目视确认)
+
+---
+
+## 9. 项目特有坑(踩过的)
+
+1. **系统 Python 3.13.14**(`C:/Users/YUAN/AppData/Local/Programs/Python/Python313/python.exe`)装了依赖和 pytest;managed 3.13.12 没装。**用系统 Python 跑测试和 uvicorn。**
+2. **本机 `.env` 已配置、bootstrap ①~⑤ 已执行,勿重做。**
+3. **演示库重灌两步缺一不可**:`scripts/core/reset.ps1` + `scripts/demo/prepare_risk_demo.sql`。只跑第一步 → 33 客户中 27 人风评过期,会误判为 bug。
+4. **sqlite 不支持绑定 Decimal**:测试里插 `core_trade.amount` 须转 float。
+5. **Milvus 数据路径必须纯英文**(faiss 不支持中文路径),已配 `C:/Users/YUAN/.jinrong/milvus/`。
+6. **文档里的旧数字不可信**:注入词表 42(应为 45)、Tool 4 个(应为 5)、专用表 5 张(应为 6)——**这些正是本次 T-101~T-103 要修的**,改代码时别拿旧数字当依据。
+
+---
+
+## 10. 完成后
+
+1. 更新 `docs/项目框架设计/TODO-架构改进.md`:勾选已完成项,标注实际用例数
+2. 更新本交接文档 §2 表格状态(若范围有变)
+3. 在 `docs/memory/2026-09-09.md` 追加实施记录(改了哪些文件、测试结果、遇到的问题)
+4. **不要 push**,报告给用户等目视确认
+
+---
+
+## 11. 有问题怎么办
+
+- **发现文档与代码不符** → 以代码为准,并在 TODO 备注里记下差异
+- **发现清单外的严重问题** → **不要顺手改**,记下来报告用户
+- **对设计有疑问** → 查《开发计划-架构改进.md》对应章节;仍不明确则停下问用户,不要猜
diff --git a/docs/项目框架设计/TODO-架构改进.md b/docs/项目框架设计/TODO-架构改进.md
new file mode 100644
index 0000000..9c495a3
--- /dev/null
+++ b/docs/项目框架设计/TODO-架构改进.md
@@ -0,0 +1,246 @@
+# TODO · 架构改进与稳定性加固
+
+> **用途**:开发执行清单。**按 T-1xx → T-2xx 顺序做**,每完成一项勾选并跑一次 pytest。
+> **上游**:《docs/PRD/PRD-架构改进与稳定性加固.md》(做什么)·《docs/项目框架设计/开发计划-架构改进.md》(怎么做)
+> **基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`,**pytest 503 绿**
+> **开工前必读**:《docs/交接文档-架构改进.md》
+> **实施记录(2026-09-09,hy3 执行)**:T-101~T-202 全部完成,pytest **511 绿**(503 基线 + 7 锁测试 + 1 守卫测试);**未新增配置项/依赖/路由,三处 run_locked 调用点未改**。注意:《架构设计说明书》实际文件为 `docs/项目框架设计/架构设计-风控模块.md`(无 §4.3.x 章节),T-104/105/106 标注已落到 §2 树(scoring.py)、§5.2(locks.py)、§5.8(G1/G2/G3)对应位置。
+
+**状态标记**:`- [ ]` 待办 · `- [x]` 已完成 · `- [/]` 进行中
+
+---
+
+## 开工前检查(必做)
+
+- [ ] 确认当前分支 `risk-control-agent`,HEAD 为 `2d0e2fa`
+- [ ] 跑一次基线:`python -m pytest -q` → 记录实际数字(应为 503 passed)
+- [ ] 确认 `locks.py` / `redis_gateway.py` 在目标合并分支**无在途改动**
+- [ ] 读《交接文档-架构改进.md》全文(5 分钟,避免踩已记录的坑)
+
+---
+
+## 第 1 批 · 合并前(极低风险)
+
+### T-101 · A1 注入词表条数修正
+
+- [ ] 改 `docs/memory/MEMORY.md` §0:`42 条` → `45 条`
+
+**验收**:与 `app/service/input_guard.py:43-94` 逐行计数一致(指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7 = 45)
+
+---
+
+### T-102 · A2 风控对话 Tool 数量修正
+
+- [ ] 改 `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 专用表数量修正
+
+- [ ] 改 `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 为预留桩
+
+- [ ] 在《架构设计说明书》§4.3.2 补明:`scoring.py` 的 `recompute_customer_score` 直接 `raise NotImplementedError`,是签名冻结的**预留桩**,非实际评分器;L3 `risk_score` 一期恒 NULL
+
+**验收**:读者不会误以为动态评分已生效
+
+---
+
+### T-105 · A5 标注 locks.py 为进程内锁
+
+- [ ] 在《架构设计说明书》§4.3.4 补明:`locks.py` 用 `threading.Lock`,是**进程内**锁,多实例部署失效为 TODO
+
+**验收**:读者知道多实例场景需先处理此项(本清单 T-201 解决)
+
+---
+
+### T-106 · G1/G2/G3 非缺陷设计标注
+
+- [ ] G1:`X-Trace-Id` 可前端伪造 —— 写明有白名单 `^[A-Za-z0-9._-]{1,64}$` 防响应头注入,**身份只认 JWT,trace_id 绝不用于权限判定**
+- [ ] G2:`trace_id` 概率唯一 —— 写明 `uuid4().hex[:16]` = 64 bit,请求级,约 2^32 次请求才 50% 碰撞,无需数据库唯一校验
+- [ ] G3:`admin.py` / `knowledge.py` 空壳 —— 写明 T-21 拍板一期只做脚本入库、未挂载路由、**空 ≠ RAG 缺失**(能力在 `service/rag_service.py`)
+
+**验收**:三项在《架构设计说明书》各有独立条目,含"为什么不是问题"
+
+---
+
+### T-107 · C4 无 key 启动告警
+
+- [ ] 在 `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 → 无该告警
+- [ ] 无循环导入
+
+---
+
+### T-108 · 审计降级告警强化(authz)
+
+- [ ] 改 `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)
+
+- [ ] 改 `app/api/audit_middleware.py:78`,补 `status` / `path` / `request_id`
+
+**验收**:日志含上述字段;失败路径仍返回原响应,**行为零变化**
+
+---
+
+### 第 1 批完成检查
+
+- [ ] `python -m pytest -q` 仍为 503 绿
+- [ ] T-107 手工冒烟通过(清空/恢复 key 各一次)
+
+---
+
+## 第 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}` |
+
+- [ ] **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))
+```
+
+- [ ] **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`**)→ 退回进程内锁,绝不向上抛
+
+- [ ] **T-201.3** 新增测试(`tests/`):
+
+| 用例 | 断言 |
+| --- | --- |
+| 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:` 前缀 |
+
+**验收**:
+- [ ] `run_locked` 签名与 `fn(locked)` 语义不变,三处调用点**无需修改**
+- [ ] 全量 pytest 仍 503 绿(新增测试计入总数)
+- [ ] 停 Redis → 业务仍可完成;恢复 Redis → 行为一致
+
+---
+
+### T-202 · B2 中间件顺序测试守卫
+
+- [ ] 新增测试:发起请求后断言 `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 执行,此处会静默为空"
+```
+
+**验收**:
+- [ ] 正常顺序下测试通过
+- [ ] **临时调换 `main.py` 两个装饰器顺序 → 测试变红**(确认有效)
+- [ ] 恢复顺序 → 转绿
+- [ ] **不**加 `ensure_trace()` 兜底(会掩盖顺序错误)
+
+---
+
+### 第 2 批完成检查
+
+- [ ] `python -m pytest -q` 全绿且用例数 ≥ 503
+- [ ] `POST /api/simulate/trade` 阻断 + 放行各一次
+- [ ] 预警聚合仍为"同客户同日仅一张 pending 单"
+- [ ] 停/启 Redis 各测一次
+- [ ] 未新增配置项、未新增依赖、未改路由
+
+---
+
+## 第 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 双写判据文档化(**判据:新增可丢留痕,变更不可丢**)
+
+---
+
+## 完成判定(总验收)
+
+- [ ] FR-01~FR-06 六项验收标准逐条通过
+- [ ] `python -m pytest -q` 全绿且用例数 ≥ 503
+- [ ] uvicorn 冒烟:`/health` 正常,无 key 告警按预期出现
+- [ ] 交易阻断 + 放行路径各一次,预警聚合行为不变
+- [ ] 停/启 Redis 各测一次,业务均可完成
+- [ ] 未新增配置项、未新增依赖、未改路由
+- [ ] **commit 但不 push**,等用户在浏览器目视确认后再推送
diff --git a/docs/项目框架设计/开发计划-架构改进.md b/docs/项目框架设计/开发计划-架构改进.md
new file mode 100644
index 0000000..af58c72
--- /dev/null
+++ b/docs/项目框架设计/开发计划-架构改进.md
@@ -0,0 +1,477 @@
+# 改进实施计划 · 全量版
+
+> **文档状态**:实施计划,**尚未动代码**。待审核通过后按批次实施。
+> **代码基线**:分支 `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 级只读账号
+
+需运维配合。注意:`core_ro.py:55` 与 `gateway_repository.py:24` **共用 `get_engine(settings.mysql_core_database)`**,要拆成两个连接串(只读账号 + 可写账号)。
+
+### 5.4 F1 · 支持 `convert`
+
+**除网关外还有两处硬编码**(最易漏):`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 "不改的范围"是否列全?
+
+**结论要求**:明确给出 可实施 / 需修改后实施 / 不建议实施。若发现设计缺陷,请直接给出替代方案。
diff --git a/docs/项目框架设计/改进方案评审-问题清单与对比.md b/docs/项目框架设计/改进方案评审-问题清单与对比.md
new file mode 100644
index 0000000..87fd15f
--- /dev/null
+++ b/docs/项目框架设计/改进方案评审-问题清单与对比.md
@@ -0,0 +1,488 @@
+# XingHuo 架构改进方案评审 · 问题清单与方案对比
+
+> **文档性质**:送审材料。供另一个 AI 在全新上下文中独立审核。
+> **代码基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`(2026-09-08),全量 pytest 503 绿。
+> **本次改动清单:无(零代码改动)。** 本轮仅产出评审材料,未修改任何业务代码。
+> **配套文档**:《docs/项目框架设计/架构设计说明书.md》(本次评审的事实来源,§7.3 与 §9.4 为本清单的初始输入)。
+
+## 审核记录
+
+### 第一轮(2026-09-09,独立 AI 全新上下文审核)
+
+**总体结论:需修改后实施(小改)。** 六项核心事实断言(A1 / A2 / B1 / B2 / C1 / F1)**全部属实**,证据确凿、推导正确,**无阻断级事实错误**。红线相关实现(审计只 INSERT、R-02 唯一阻断路径、Core 只读)在代码中均有落点,未发现颠覆性安全或正确性遗漏。
+
+审核提出 3 项重要修正 + 2 项可选修正,**本轮已全部吸收**,修订点如下:
+
+| # | 审核意见 | 档位 | 本轮处理 | 位置 |
+| --- | --- | --- | --- | --- |
+| 1 | 只加 UNIQUE 不加重试,并发会从静默脏数据变成 500 硬失败 | 重要 | 已补充实施配套要求 | §3 B1 |
+| 2 | §5 缺"生产部署拓扑(单进程 vs 多实例)"决策项 | 重要 | 已新增待确认第 7 项 | §5 |
+| 3 | 放开 convert 后,聚合层两处 SQL 过滤会让它"隐形" | 重要 | 已补充并标为易漏点 | §3 F1 |
+| 4 | `next_seq_no` + `insert_message` 是更危险的并行取号路径 | 可选 | 已补充警示 | §3 B1 |
+| 5 | C1 严重度略夸大(方法全为固定 SELECT,非纯约定) | 可选 | 已校正表述 | §3 C1 |
+
+**未吸收意见**:无。
+
+---
+
+## 0. 结论速览
+
+| 状态 | 数量 | 说明 |
+| --- | --- | --- |
+| 未修复 · 建议修 | 9 | 正确性隐患与合规风险,见 §3 B/C 类 |
+| 未修复 · 文档口径错误 | 5 | 改文档即可,零代码风险,见 §3 A 类 |
+| 待确认 · 需人工拍板 | 4 | 涉及接口协议或架构选型,见 §5 |
+| 非缺陷 · 仅需文档标注 | 3 | 设计正确但易被误读,见 §3 G 类 |
+| **合计** | **21** | |
+
+**三条最需要优先处理的**(按"后果严重性 × 触发概率"排序):
+
+1. **B1** `agent_message` 的 `(session_id, seq_no)` 是普通索引而非唯一索引 → 并发同会话会**静默产生重号消息**(本轮新发现,此前未记录)
+2. **C1** `core_ro` 只读纯靠约定,无 DB 级账号保护 → 项目五条红线之一是"Core C1~C5 不可被覆盖",当前无任何强制手段
+3. **B2** 中间件执行顺序无测试守卫 → 调换两个装饰器会**静默丢失全站 trace_id**,且不报错
+
+---
+
+## 1. 本次改动清单
+
+**无。**
+
+原因:本轮会话(2026-09-09)全程为架构讲解与术语答疑,**未形成任何经确认的改动方案**。用户于本轮末尾选定"先出方案对比文档,不改码",故本轮仅产出本材料,等待拍板后再实施。
+
+| 文件 | 改动点 | 原因 |
+| --- | --- | --- |
+| — | — | 本轮零代码改动 |
+
+---
+
+## 2. 问题汇总表
+
+| ID | 问题 | 类别 | 状态 | 影响范围 | 优先级 |
+| --- | --- | --- | --- | --- | --- |
+| A1 | 注入词表文档写 42 条,实测 45 条 | 文档口径 | 未修复 | `MEMORY.md` §0 | 低 |
+| A2 | 风控对话 Tool 文档写 4 个,实测 5 个 | 文档口径 | 未修复 | `MEMORY.md` 仓库地图 | 低 |
+| A3 | Agent 专用表 SQL 注释写 5 张,实建 6 张 | 文档口径 | 未修复 | `02-mysql-agent专用.sql` | 低 |
+| A4 | `scoring.py` 实为 `NotImplementedError` 桩,文档未标明 | 文档口径 | 未修复 | L3 `risk_score` 恒 NULL | 中 |
+| A5 | `locks.py` 实为进程内锁,文档未标明 | 文档口径 | 未修复 | 多实例部署 | 中 |
+| B1 | `agent_message` 的 seq 索引非唯一,并发会静默重号 | 正确性 | 未修复 | 消息顺序 / LLM 上下文 | **高** |
+| B2 | 中间件执行顺序无测试守卫 | 正确性 | 未修复 | 全站 trace_id | **高** |
+| B3 | `deny()` 双写无事务,可能只写一半 | 正确性 | 未修复 | 安全台账完整性 | 中 |
+| B4 | `record_trade_alerts` 出单双写无事务 | 正确性 | 未修复 | 审计完整性 | 中 |
+| B5 | 限流 `INCR` + 首命中 `EXPIRE` 非原子 | 正确性 | 未修复 | 限流窗口 | 中 |
+| B6 | `locks.py` 进程内锁,多实例失效 | 正确性 | 未修复 | 预警聚合 / L3 | 中 |
+| C1 | `core_ro` 无 DB 级只读账号 | 安全合规 | 未修复 | Core 红线 | **高** |
+| C2 | dev debug 通道误配即放开无签名身份 | 安全合规 | 未修复 | 生产环境 | **高** |
+| C3 | 审计 / 限流 fail-open 策略不可配置 | 安全合规 | 未修复 | 合规强场景 | 中 |
+| C4 | 无 DeepSeek key 时静默降级,无告警 | 安全合规 | 未修复 | 生产可观测性 | 中 |
+| D1 | `utils/logger.py` 仅一行 docstring,日志无落盘 | 可观测性 | 未修复 | 全站排障能力 | 中 |
+| E1 | 权限逻辑分散在矩阵 / chat / tool 三处 | 可维护性 | 未修复 | 改权限需三处同步 | 中 |
+| E2 | Tool 注册表分裂为三套 | 可维护性 | 未修复 | 新增 Tool | 低 |
+| E3 | `model/` 层闲置,跨层全用 dict | 可维护性 | 未修复 | 类型安全 | 中 |
+| E4 | 注入词表硬编码 45 条,扩展需改代码 | 可维护性 | 未修复 | 对抗性 prompt | 低 |
+| F1 | 不支持 `convert` 交易类型(DB 支持,网关拒绝) | 能力 | 待确认 | 接口协议 | 待确认 |
+| F2 | 意图识别为关键词匹配,换说法即漏触 | 能力 | 待确认 | 对话命中率 | 待确认 |
+| F3 | R-05 动态评分未实现 | 能力 | 未修复 | L3 评分 | 低 |
+| G1 | `trace_id` 可前端伪造 | 非缺陷 | 设计如此 | 需文档标注 | — |
+| G2 | `trace_id` 概率唯一,无冲突检测 | 非缺陷 | 设计如此 | 需文档标注 | — |
+| G3 | `admin.py` / `knowledge.py` 空壳 | 非缺陷 | 一期范围 | 需文档标注 | — |
+
+> 说明:F1~F3 属能力增强而非缺陷,是否做取决于产品排期;G 类为"看起来像问题但设计正确",只需在文档中写明,避免后续被重复提出。
+
+---
+
+## 3. 逐条方案对比
+
+### A 类 · 文档口径错误(5 项,改文档即可)
+
+**A1** `input_guard.py:43-94` 实测 45 条(指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7),`MEMORY.md` §0 写 42 条。
+
+- 方案 A:改 `MEMORY.md` 为 45 条 —— 零风险,10 秒完成
+- 方案 B:不改 —— 后续读者按 42 条核对会误判
+- **建议**:A。同源问题 A2/A3 一并改。
+
+**A2** `chat_tools.py:328` `RISK_TOOL_REGISTRY` 实际注册 5 个(`query_overdue_alerts` / `alert_query` / `customer_context` / `suitability_check` / `aml_lookup`),另有 `query_agent_behavior:276` 定义但未注册。文档写"四个"。
+
+**A3** `02-mysql-agent专用.sql` 注释写 5 张,实建 6 张(多出 `risk_aml_list`)。与 `entities.py` 注释"jinrong_agent 17 张"一致(11+6)。
+
+**A4** `service/risk/scoring.py:18` `recompute_customer_score` 直接 `raise NotImplementedError`,是签名冻结的预留桩,**不是实际评分器**。L3 的 `risk_score` 一期恒 NULL。文档应明示,否则读者会以为评分已生效。
+
+**A5** `service/risk/locks.py:31` 用 `threading.Lock`,是**进程内**锁,注释自陈"多进程部署换 Redis SET NX,接口不变"——**是 TODO,非已完成**。
+
+---
+
+### B 类 · 正确性隐患
+
+#### B1 `agent_message` 并发会静默产生重号消息(本轮新发现)
+
+**证据链**:
+
+1. `session_repository.py:241-252`:`insert_turn` 在事务内取 `MAX(seq_no)+1`
+2. 该事务的 SELECT 是**非锁定读**,MySQL InnoDB 默认 REPEATABLE READ 下两个并发事务仍可读到相同的 `MAX` 值
+3. `01-mysql-共用底座.sql` 中 `agent_message` 只有 `KEY idx_session_seq (session_id, seq_no)` —— **普通索引,非 UNIQUE**
+4. 代码注释自陈:"并发同会话不重号(**并发写锁归后续**)"——承认未做完
+
+**后果**:两个事务算出同一个 `seq`,各自 INSERT 成功,**产生两条 seq_no 相同的消息**。`list_messages` 按 `seq_no` 排序时,同号两条的相对顺序由物理存储决定 → LLM 读到的上下文可能乱序("AI 先答、用户后问")。
+
+**触发条件**:同一会话并发请求——多端同时发问、前端重复提交、SSE 断连重试。单用户串行对话不会触发。
+
+| 方案 | 做法 | 收益 | 代价 |
+| --- | --- | --- | --- |
+| **A(推荐)** | 给 `(session_id, seq_no)` 加 UNIQUE 索引 | 重号从"静默脏数据"变为"数据库报错",可被 `insert_turn` 捕获后重试;同时提升查询性能 | 需先核查历史数据有无重号,有则先清洗;DDL 变更需确认 |
+| B | 会话级 Redis 锁串行化 | 彻底避免并发 | 引入新依赖点,Redis 挂时退化;需设计锁粒度与超时 |
+| C | 改用 `AUTO_INCREMENT` 的 `id` 排序 | 零冲突 | 改变现有 seq 语义,影响 `list_messages_page` 分页与前端 |
+
+**建议**:优先 A。它是唯一能让"问题显式暴露"的方案——B/C 只是绕开,A 是把隐性错误变成显性失败,符合项目"宁多勿漏"的一贯口径。
+
+> **⚠️ 实施 A 的必要配套(第一轮审核补充)**
+> 当前 `insert_turn` **没有任何 `IntegrityError` 捕获与重试逻辑**。若只加 UNIQUE 索引而不加重试,并发同会话请求会从"静默脏数据"直接变成 **500 硬失败**——问题同样没解决,只是换了个表现形式。
+> 实施时必须同步补:捕获 `IntegrityError` → 重读 `MAX(seq_no)` → 重试(建议 3 次,与 L3 乐观锁的重试次数保持一致)。
+
+> **⚠️ 另有一条更危险的并行取号路径(第一轮审核补充)**
+> `session_repository.py:134` `next_seq_no()` 用**非事务**的 `.connect()` 取号,`:142` `insert_message()` 单独 INSERT——**取号与写入分离,连事务都没有**,竞态比 `insert_turn` 更严重(连"同事务"这层缓解都没有)。
+> `insert_turn` 是后来为修方案 C 评审 P0/P1 才新增的原子路径,旧的组合方式仍留在代码里。建议统一走 `insert_turn`,废弃 `next_seq_no + insert_message` 组合,或至少在方法 docstring 加警示。
+
+#### B2 中间件执行顺序无测试守卫
+
+**现状**:`main.py` 中 audit 先注册(81 行)、trace 后注册(87 行)。Starlette `add_middleware` 用 `insert(0)` 且 `build_middleware_stack` 反向包裹 → **数组越靠前越外层 → trace 在外层先执行**,audit 在内层,与其注释"执行序在 trace 之内"吻合。
+
+**风险**:调换两个装饰器位置后,audit 会在 trace 之前执行,`current_trace()` 返回空串,**全站审计静默丢失 trace_id**,且不报错、不失败。
+
+| 方案 | 做法 | 收益 | 代价 |
+| --- | --- | --- | --- |
+| **A(推荐)** | 加断言测试:发起请求后校验 `audit_log.trace_id` 非空 | 零业务代码改动,回归即发现 | 需访问数据库断言,或 mock 仓储捕获入参 |
+| B | 在 `audit_middleware` 入口显式 `ensure_trace()` | 兜底生成,不依赖顺序 | 掩盖顺序错误,trace_id 与响应头不一致 |
+
+**建议**:A。B 会让"顺序错了"这件事从可见故障变成隐性不一致,反而更难查。
+
+#### B3 / B4 两处双写无事务
+
+- **B3** `utils/authz.py:44-76`:两次 insert 在同一 `try` 内但**无事务** → 可能 `audit_log` 成功、`input_guard_log` 失败
+- **B4** `alert_service.py:174-176`:`insert_alert` 与 `_audit` 两次独立 INSERT,**无事务** → 可能出单成功、审计丢失
+
+**对照**:`handle_alert_with_audit`(`risk_repository.py:331`)**是同事务的**,注释明确"审计失败整体回滚,不产生无痕状态变更"。
+
+| 方案 | 做法 | 收益 | 代价 |
+| --- | --- | --- | --- |
+| A | 三处统一改同事务 | 语义一致,审计完整 | 审计故障会连带回滚业务单据,与当前 fail-open 口径冲突,需先定 C3 |
+| B | 维持现状,但加"审计写入失败"计数指标与告警 | 保留可用性优先,同时让丢失可见 | 需引入指标设施(当前无) |
+| **C(建议)** | 维持现状 + 在文档中固化判据:"新增可丢留痕,变更不可丢" | 零改动,消除后续争议 | 无 |
+
+**建议**:C 为当前最优。B3/B4 都是"新增"场景,最坏少一条留痕,业务单据仍在,与 §3 中"处置双写必须同事务"形成清晰判据。**但这条判据目前只存在于代码注释里,应写进文档**。
+
+#### B5 限流 `INCR` + 首命中 `EXPIRE` 非原子
+
+`input_guard.py` 的 `check_rate_limit`:首次命中时先 `INCR` 再 `EXPIRE`,两步之间进程崩溃会留下**无 TTL 的计数键** → 该 actor 被永久限流。
+
+| 方案 | 做法 | 代价 |
+| --- | --- | --- |
+| A | 改用 Lua 脚本或 `SET key 1 EX 60 NX` + `INCR` 组合 | 需改 Redis 调用方式 |
+| B | 用 Redis 的 `EXPIRE` 幂等重试 / 每次 INCR 后无条件 EXPIRE | 极小改动:每次都发 EXPIRE,不做"首命中"判断 |
+
+**建议**:B,改动一行级别,可接受。
+
+#### B6 进程内锁多实例失效
+
+`locks.py:31` 用 `threading.Lock`,key 为 `agg:event:{customer_id}:{date}` / `l3:{customer_id}`。多进程/多实例部署时,两个实例各自持锁,**锁形同虚设**。
+
+**缓解现状**:L3 更新另有乐观锁(`computed_at` 比对 + 3 次重试)兜底,跨进程天然有效,所以最坏是"多出一张重复单"而非数据错乱。
+
+| 方案 | 做法 | 代价 |
+| --- | --- | --- |
+| A | 换 Redis `SET key val NX EX ttl`,接口 `run_locked` 不变 | 引入对 Redis 的强依赖,Redis 挂时退化为无锁(当前已 fail-open) |
+| B | 维持现状,文档标注"单进程部署前提" | 零成本,但多实例时失效 |
+
+**建议**:B(标注前提)+ 待多实例部署时再做 A。注释已预留接口,切换成本可控。
+
+---
+
+### C 类 · 安全与合规
+
+#### C1 `core_ro` 无 DB 级只读账号(红线相关)
+
+项目五条红线之一:"Core 正式 C1~C5 不可被画像覆盖"。当前保障手段仅为:双库物理隔离 + `core_ro.py` 类注释"仅 SELECT"。
+
+**实际风险**:任何持有 `mysql_core_database` engine 的代码都能执行 INSERT/UPDATE,**无 SQL 层拦截、无 DB 只读账号**。唯一允许写 Core 的 `gateway_repository.insert_trade` 与 `core_ro` 相比只是"另一个类",无任何机制性区分。
+
+> **审核校正(第一轮)**:`core_ro` 的方法**全部是硬编码的固定 SELECT**,要破坏它需要主动新增写方法,因此比"纯君子协定"略强。但本质上仍是"靠人不犯错",而非机制保障。**严重度维持高**,理由表述以本段为准——不夸大,也不因"暂时没出事"而降级。
+
+| 方案 | 做法 | 收益 | 代价 |
+| --- | --- | --- | --- |
+| **A(推荐)** | 为 `core_ro` 配置独立的 MySQL 只读用户(GRANT SELECT ONLY) | 从根基卡死,代码怎么写都写不进去 | 需运维配合;`gateway_repository` 必须用另一个有写权限的账号,连接串要拆分 |
+| B | 代码层加 SQL 白名单校验(只允许 SELECT 开头) | 不依赖运维 | 易被绕过(注释、子查询、存储过程);有性能开销 |
+| C | 维持现状 | 零成本 | 红线无强制保障 |
+
+**建议**:A。这是唯一"不可逆"的保障方式——B 是软约束,C 无保障。注意 A 需要拆连接串:`get_engine(settings.mysql_core_database)` 目前只有一个账号,读写共用。
+
+#### C2 dev debug 通道误配风险
+
+`deps.py:258`:仅当 `app_env == "development"` **且** `jwt_public_key_path` 为空时才走 `X-Debug-Role` / `X-Debug-Actor` 兜底。
+
+**风险**:生产环境若误配 `app_env=development` 且未设公钥 → 任何人都能用 `X-Debug-Role: risk_officer` 头伪造身份,且**无签名**。
+
+**现有缓解**:`main.py:49-57` 的 lifespan 在非 development 环境强制校验 JWT 就绪,且 `AUTH_FACTORY_IS_DEBUG` 双保险。
+
+| 方案 | 做法 | 代价 |
+| --- | --- | --- |
+| A | 维持现状,在 `.env.example` 与部署文档加粗警示 | 零成本 |
+| B | 启动时若 `app_env=development` 打 WARNING(已有类似:dev secret 默认值告警) | 一行代码 |
+
+**建议**:A + B 组合。B 可复用 `main.py:61-65` 现有的 dev secret 告警模式,改动极小。
+
+#### C3 审计 / 限流 fail-open 策略不可配置
+
+`utils/authz.py:11-12` 注释自陈"降级口径与 T-02 审计一致……生产可切 fail-closed(**待决,已登记**)"。
+
+当前所有审计失败都是 fail-open(仅 `logger.exception`),业务语义不变。合规强场景可能需要 fail-closed。
+
+**建议**:纳入 §5 待确认事项,需要业务方明确"审计丢失"与"业务中断"哪个更不可接受。技术上是加一个配置项的事,但**这是业务决策不是技术决策**。
+
+#### C4 无 DeepSeek key 时静默降级
+
+`agent_service.py:148-150`:`if not settings.deepseek_api_key: reply = _degraded_reply(state)`,返回带 `_DEGRADED_PREFIX` 的摘要,**不抛异常、不告警**。
+
+**风险**:生产漏配 key 时,系统"看起来正常",只是所有回答都是降级内容。用户和运维都难以察觉。
+
+| 方案 | 做法 | 代价 |
+| --- | --- | --- |
+| **A(推荐)** | 启动时若 key 缺失,打 WARNING 日志(可复用 `lifespan` 里 `jwt_ready()` 的模式) | 一行代码 |
+| B | 降级回复中明确告知用户"当前为降级模式" | 影响演示体验 |
+| C | 生产环境 key 缺失直接启动失败 | 影响演示/CI |
+
+**建议**:A。`main.py` 已有 `jwt_ready()` 的启动告警先例,风格统一。
+
+---
+
+### D 类 · 可观测性
+
+#### D1 `utils/logger.py` 完全占位
+
+文件内容仅 `"""日志模块。"""` 一行。全站 `logger.warning/exception` 走 Python 默认配置,**只输出到控制台,无格式化、无落盘、无轮转、未接 trace_id**。
+
+对比:审计(`audit_log`)做得相当扎实——六处落点、只 INSERT、结构化可查。日志侧则是空白。
+
+| 方案 | 做法 | 收益 | 代价 |
+| --- | --- | --- | --- |
+| A | 配置 `logging.basicConfig` + 文件 handler + 格式化串带 trace_id | 排障能力质变 | 需注入 trace_id 到 LogRecord(可用 filter) |
+| B | 引入 `structlog` / `loguru` | 结构化日志 | 新增依赖,需确认(技术选型硬阀门) |
+| C | 维持现状 | 零成本 | 线上问题基本无法追溯 |
+
+**建议**:A 起步。用 `logging.Filter` 把 `current_trace()` 注入每条日志记录,是收益/成本比最高的改动,且无需新增依赖。
+
+---
+
+### E 类 · 可维护性
+
+| ID | 问题 | 方案 | 代价 |
+| --- | --- | --- | --- |
+| E1 | 权限逻辑分散三处(矩阵 `deps.py:46` / chat 显式 deny `chat.py:122` / tool fail-closed `tool_service.py:138`) | A:维持三层纵深,但在文档固化"三层各自职责"
B:收拢到一处配置 | A 零成本
B 改动大,且会削弱纵深防御 |
+| E2 | Tool 注册表分裂为 core / risk / kb 三套 | A:抽统一 `ALL_TOOLS` 聚合器 | 低风险,但收益有限 |
+| E3 | `model/` 闲置(31 个 ORM 类不用),跨层全 dict 传参 | A:逐步用 `TypedDict` 约束仓储入参
B:维持现状 | A 渐进式,无编译期风险
B 保持敏捷但拼写错误运行时才暴露 |
+| E4 | 注入词表硬编码 45 条 | A:配置化(`.env` 或 DB)
B:维持现状 + 持续红队补充 | A 需设计加载与热更新 |
+
+**E1 建议**:A。三层不是冗余,是纵深防御——① 是能力声明、② 是业务线约束、③ 是兜底。收拢反而危险。只需写清文档避免后人"好心合并"。
+
+**E3 建议**:A 渐进式。优先给 `insert_alert` / `insert_audit_log` 这类高频入口加 `TypedDict`。
+
+---
+
+### F 类 · 能力增强(非缺陷)
+
+#### F1 支持 `convert` 转换交易(用户指定纳入)
+
+**现状**:
+
+- `scripts/core/01-ddl.sql:152`:`trade_type ENUM('subscribe','redeem','convert')` —— **数据库支持**
+- `trade_gateway.py:36`:`SUPPORTED_TRADE_TYPES = ("subscribe", "redeem")`
+- `trade_gateway.py:112-113`:`if trade_type == "convert": raise UnsupportedTradeType(CONVERT_MESSAGE)`
+- 提示语:`"转换交易暂不支持,请分别发起申购/赎回"`
+
+即:**Core(模拟真实交易系统)有这个能力,网关主动收窄了**。
+
+**为什么当前不支持(代码层面的硬约束)**:
+
+1. 请求体 `req` 只有 `{customer_id, product_id, trade_type, amount}` —— **单个 `product_id`**
+2. `check_suitability(customer_id, product_id)` 只接受一个产品 —— 转换需要校验**转入那只**是否匹配客户风险等级
+3. `amount` 语义会分裂:申购/赎回时是金额,转换时是份额
+
+**方案对比**:
+
+| 方案 | 做法 | 收益 | 代价 / 风险 |
+| --- | --- | --- | --- |
+| **A** | 请求体加 `target_product_id`,`convert` 时校验转入产品适当性,转出产品走持仓校验 | 业务完整,符合真实交易系统能力 | **改接口协议(需确认)**;引擎规则要重新定义(转换算不算当日累计?算几笔?);审计口径、前端都要跟着改 |
+| **B** | 维持不支持,仅优化提示语与文档说明 | 零风险 | 业务缺口保留 |
+| **C** | 网关层把 convert 拆解为 redeem + subscribe 两笔内部交易 | 不改接口协议,复用现有校验 | 语义变化大(变成两笔交易),`trade_id` 与审计口径都要变;金额换算复杂 |
+
+**建议**:本轮不做,列入待确认。若要做,**方案 A 是唯一正确的**——C 会把一笔业务变成两笔,在金融审计上是灾难。
+
+> **⚠️ 除网关外还有两处硬编码过滤(第一轮审核补充,最易漏)**
+> `core_ro.py:390`(`list_trades_range`)与 `:432`(`sum_trades_on_date`)的 SQL 里写死:
+> ```sql
+> AND trade_type IN ('subscribe', 'redeem')
+> ```
+> 即便网关放开 convert,**这两处不改的话,convert 交易在 RISK-001(大额)/ RISK-002(当日累计)/ RISK-003(频繁交易)的聚合统计中会完全"隐形"**——规则引擎压根看不到它,等于给大额与累计规则开了一个绕过口子。
+> 这是放行 convert 时最容易漏掉的一处,且**漏了不会报错**,只会静默少算。
+
+**前置依赖**:需产品明确三件事:① 转换的适当性校验对象是转入产品还是两只都校验;② 转换金额是否计入 `risk_daily_total` 当日累计;③ 转换在 RISK-003 频繁交易规则中算一笔还是两笔。
+
+#### F2 意图识别为关键词匹配
+
+`tool_service.py:100-112` `match_intent`:按 agent 分组遍历关键词,`any(k in message for k in kws)`,**命中即停**(单意图)。
+
+- 换说法即漏触("我的基金" vs "持仓")
+- 不支持多意图("我的持仓和适当性"只取首条)
+
+| 方案 | 收益 | 代价 |
+| --- | --- | --- |
+| A:换 LLM function calling | 支持多意图、泛化好 | 增加一次 LLM 调用延迟与成本;判定不可复现,不利于审计 |
+| B:扩充同义词表 + 支持多意图 | 保持零延迟、可复现、可单测 | 仍需人工维护词表 |
+| C:混合(关键词优先,未命中降级 LLM) | 兼顾 | 实现复杂度最高 |
+
+**建议**:若命中率是痛点,先做 B(成本低、保持可审计性);A 的"不可复现"在金融审计场景下是明显劣势。
+
+#### F3 R-05 动态评分未实现
+
+`scoring.py` 抛 `NotImplementedError`,签名已冻结。L3 `risk_score` 一期恒 NULL。是否做取决于产品排期。
+
+---
+
+### G 类 · 非缺陷(设计正确,仅需文档标注)
+
+| 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`) |
+
+---
+
+## 4. 风险点与回归验证步骤
+
+本节针对"若决定实施上述改动"给出。**本轮未改动任何代码,以下为预案。**
+
+### 4.1 通用风险
+
+| 风险 | 说明 | 缓解 |
+| --- | --- | --- |
+| 测试基线漂移 | 当前基线 503 绿 | 任何改动前先跑全量确认基线,改动后对比 |
+| 路由清单失配 | `tests/test_main.py::test_all_routers_mounted` 硬编码路由清单 | 增改路由必须同步更新该清单,否则必红 |
+| SQLite / MySQL 差异 | 单测用 SQLite,生产 MySQL | 涉及 DDL / 日期函数的改动需双跑 |
+| 双库事务 | 跨库无法 JOIN、无分布式事务 | 任何跨库改动只能最终一致,需补偿机制 |
+
+### 4.2 分类回归清单
+
+**若改 A 类(文档口径)**
+
+- 无代码改动 → 无需回归测试
+- 验证:人工核对 `MEMORY.md` 与代码计数一致
+
+**若改 B1(加 UNIQUE 索引)**
+
+1. 先执行 `SELECT session_id, seq_no, COUNT(*) FROM agent_message GROUP BY session_id, seq_no HAVING COUNT(*) > 1` 确认历史无重号
+2. 有重号 → 先清洗再建索引
+3. 建索引后跑全量 pytest
+4. 并发用例:模拟同会话并发 `insert_turn`,确认抛异常而非静默重号
+5. 验证 `list_messages_page` 分页不受影响
+
+**若改 B2(加中间件顺序测试)**
+
+1. 新增测试:发起任意请求 → 断言 `audit_log` 中该行 `trace_id` 非空
+2. 反向验证:临时调换装饰器顺序 → 测试应变红(确认测试有效)
+3. 恢复顺序 → 测试转绿
+
+**若改 B5(限流 EXPIRE)**
+
+1. 单测覆盖:首次请求后键存在且带 TTL
+2. 模拟 EXPIRE 失败 → 确认不产生无 TTL 键
+
+**若改 C1(只读账号)**
+
+1. 拆分连接串:`core_ro` 用只读账号,`gateway_repository` 用可写账号
+2. 验证 `submit_trade` 仍能写 `core_trade`
+3. 验证 `core_ro` 所有查询正常
+4. 尝试用 `core_ro` 执行 INSERT → 应被数据库拒绝
+5. 全量 pytest + uvicorn 冒烟三端点
+
+**若改 C4(启动告警)**
+
+1. 清空 `.env` 的 `DEEPSEEK_API_KEY` → 启动应打 WARNING
+2. 填回 → 无告警
+
+**若改 D1(日志配置)**
+
+1. 验证日志落盘
+2. 验证 trace_id 出现在每条记录中
+3. 验证并发请求下 trace_id 不串号(关键:contextvar 传播)
+
+**若改 F1(支持 convert)**
+
+1. 接口协议变更需先经确认
+2. 单测:convert 正常路径、转入产品适当性不匹配路径、持仓不足路径
+3. 引擎回归:确认转换对 RISK-001/002/003 的计数影响符合产品定义
+4. 审计回归:确认 convert 落审计的 `decision` 取值正确
+5. 前端/演示脚本同步
+
+### 4.3 最小回归命令
+
+```bash
+# 1. 建立基线
+python -m pytest -q # 期望 503 passed
+
+# 2. 启动冒烟
+uvicorn app.main:app --reload
+curl http://127.0.0.1:8000/health # 期望 {"status":"ok",...}
+
+# 3. 接口实调(按演示 SOP)
+# - POST /api/simulate/trade 阻断路径 + 放行路径
+# - POST /api/chat 同步对话
+# - POST /api/chat/stream SSE 流式(确认 [DONE] 与落库)
+# - GET /api/risk/alerts 台账只读
+
+# 4. 改动后重跑 1~3,对比基线
+```
+
+---
+
+## 5. 待确认事项(需人工拍板,本轮不擅自决定)
+
+| # | 事项 | 为什么需要拍板 | 关联 ID |
+| --- | --- | --- | --- |
+| 1 | 审计失败是否从 fail-open 切 fail-closed | 业务决策:审计丢失 vs 业务中断,哪个更不可接受 | C3 |
+| 2 | 是否支持 `convert` 交易类型 | 改接口协议;需产品定义三条业务口径(见 F1) | F1 |
+| 3 | 是否为 `core_ro` 建 DB 只读账号 | 需运维配合;连接串要拆分 | C1 |
+| 4 | 意图识别是否升级为 LLM | 命中率 vs 可审计性的取舍 | F2 |
+| 5 | 是否引入日志库(`structlog` / `loguru`) | 涉及技术选型硬阀门,新增依赖需确认 | D1 |
+| 6 | `insert_turn` 并发控制:加唯一索引 or 引入会话锁 | 方案选择,涉及 DDL 变更;且须同步加重试(见 B1) | B1 |
+| 7 | 生产部署拓扑:单进程 or 多实例 | `locks.py` 进程内锁、`insert_turn` 取号竞态**均以单进程为前提**;多实例时 B6 锁直接失效、B1 触发概率显著上升。此项不拍板,B1/B6 的方案选择无从谈起(第一轮审核补充) | B1 / B6 |
+
+---
+
+## 6. 给审核 AI 的检查清单
+
+请逐项核验并在审核意见中给出结论:
+
+**事实核验(对照代码,勿信本文断言)**
+
+- [ ] §2 表中每一项的状态(未修复 / 待确认 / 非缺陷)是否与代码一致
+- [ ] A1~A5 的"文档口径 vs 实测"数值是否属实(尤其是 A1 的 45 条、A2 的 5 个 Tool)
+- [ ] B1 的并发重号问题:确认 `agent_message` 索引确为普通 KEY 而非 UNIQUE,确认 `insert_turn` 的 SELECT 未加锁
+- [ ] B2 的中间件栈序推导是否正确(Starlette `add_middleware` 的 `insert(0)` + `build_middleware_stack` 的 `reversed`)
+- [ ] C1 是否真的无法写 Core(确认无 DB 级只读账号、无 SQL 层拦截)
+- [ ] F1 中"DB 支持 convert 但网关拒绝"是否属实
+
+**方案评估**
+
+- [ ] 每条"建议"是否与其代价分析自洽(有无只谈收益不谈代价)
+- [ ] B1 推荐方案 A(加唯一索引)是否会破坏现有数据或测试
+- [ ] C1 推荐方案 A(只读账号)是否与 `gateway_repository` 需写 Core 冲突,拆分连接串是否可行
+- [ ] E1 建议"维持三层权限纵深"是否合理,还是确实应该收拢
+
+**遗漏检查**
+
+- [ ] 是否存在本文未覆盖的正确性或安全性问题
+- [ ] §4 回归清单是否覆盖了所有高风险改动
+- [ ] §5 待确认事项是否有遗漏(尤其是需要业务方而非技术方决策的)
+
+**结论要求**
+
+- 明确给出:可实施 / 需修改后实施 / 不建议实施
+- 若发现本文事实性错误,请直接指出并给出正确结论
diff --git a/docs/项目框架设计/架构设计-风控模块.md b/docs/项目框架设计/架构设计-风控模块.md
index 4c48c29..06a0401 100644
--- a/docs/项目框架设计/架构设计-风控模块.md
+++ b/docs/项目框架设计/架构设计-风控模块.md
@@ -37,7 +37,7 @@ app/
│ ├── alert_service.py # 预警单聚合/去重/落库/更新 + Pub/Sub 推送
│ ├── aml_service.py # AML:姓名归一化 + difflib 相似度匹配 + 全量扫描
│ ├── profile_l3.py # L3 UPSERT(最高档合并 + tags 追加)后 DEL profile:l3:{cid} 缓存
-│ └── scoring.py # R-05 预留:本期静态映射 recompute_customer_score(customer_id)
+│ └── scoring.py # R-05 预留桩(非实际评分器):recompute_customer_score(customer_id) 直接 raise NotImplementedError(签名已冻结,待 R-05 实现);L3 的 risk_score 一期恒 NULL,由 R-05 首写,避免静态分污染语义
│
├── repository/
│ ├── core_ro.py # 【扩展(最小化)】SUIT 校验复用现有 get_customer_l0
@@ -166,7 +166,7 @@ def current() -> str: return _trace_id.get()
### 5.2 聚合去重实现(PRD FR-4 · 首单并发正确性)
- **先取锁再 check-insert**(`risk_alert` 无 `(customer_id, date)` 唯一键且冻结不改表,裸 SELECT FOR UPDATE 在并发首单时空结果集锁不住,会产生双单):
- - 进程内锁:`threading.Lock` 字典,key=`agg:{customer_id}:{alert_class}:{date}`(alert_class ∈ event/suitability);当前单进程部署即正确,多进程部署时替换为 Redis `SET NX EX` 锁(接口不变)
+ - 进程内锁:`locks.py` 用 `threading.Lock` 字典,key=`agg:{customer_id}:{alert_class}:{date}`(alert_class ∈ event/suitability);**这是进程内锁,仅活在当前进程内存,多实例部署时两实例各有各的锁、同一客户并发会同时进临界区(原设计为 TODO,非已完成)**。本期(架构改进 T-201)已将其改造为「Redis 分布式锁为主 + 进程内锁为备」的双层结构:Redis 不可用时退回进程内锁,单进程行为不变;多实例场景由 Redis 锁提供跨进程互斥。
- **锁内**普通 `SELECT ... WHERE customer_id=? AND status='pending_review' AND alert_type IN (事件类) AND created_at >= 当日 00:00` → 存在则读改写 `payload.events[]` + `triggered_rules` 合并 + `risk_score=max` + `alert_type=最高分规则类型`;不存在 INSERT
- **降级策略**:锁获取超时(>2s)不阻塞交易——放行并照常独立出单(宁多勿漏),记 logger.warning
- suitability 单:同上,键 `customer_id + alert_class='suitability' + payload.product_id`
@@ -208,6 +208,16 @@ hit = (norm(name) == norm(list_name)) or \
---
+### 5.8 设计标注(非缺陷项 · 评审记录)
+
+下列三项在评审中被列为「非问题」,单独列出避免读者误判为缺陷:
+
+- **G1 · `X-Trace-Id` 允许前端伪造**:`main.py` trace 中间件对传入头做白名单 `^[A-Za-z0-9._-]{1,64}$` 校验,不合规一律丢弃并新生成,防响应头注入。**身份只认 JWT,trace_id 绝不参与任何权限判定**,伪造它最多改个日志追踪号,动不了鉴权。
+- **G2 · `trace_id` 概率唯一**:生成用 `uuid4().hex[:16]`(64 bit 熵),为请求级标识,约 2^32 次请求才有 50% 碰撞概率,无需数据库唯一性校验;碰撞最坏后果是日志追踪归并错误,非数据问题。
+- **G3 · `admin.py` / `knowledge.py` 空壳**:T-21 拍板一期只做脚本入库(知识库已建集合并 upsert,能力在 `service/rag_service.py`),这两文件未挂载路由。**空 ≠ RAG 缺失**,RAG 检索能力已就绪,缺的只是前端上传/重建端点(产品排期后置)。
+
+---
+
## 6. 配置新增(`.env.example` 同步)
```ini
diff --git a/docs/项目框架设计/架构设计说明书.md b/docs/项目框架设计/架构设计说明书.md
new file mode 100644
index 0000000..799b068
--- /dev/null
+++ b/docs/项目框架设计/架构设计说明书.md
@@ -0,0 +1,682 @@
+# XingHuo 智能财富管家 · 架构设计说明书
+
+> **代码基线**:分支 `risk-control-agent`,HEAD `2d0e2fa`(2026-09-08),全量 pytest 503 绿。
+> **编写原则**:本文所有论断均落到具体文件、类、函数、行号;凡文档口径与代码实测不一致处,以代码为准并在 §9.4 列出。
+> **与既有文档的关系**:`FRAMEWORK.md` 是选型与状态的速查表,`FLOW.md` 是端到端链路与 bootstrap,本文是**设计决策与理由的系统阐述**——回答"为什么是这样"以及"换成别的会怎样"。
+
+---
+
+## 0. 阅读指南
+
+| 你想知道 | 直接看 |
+| --- | --- |
+| 系统整体长什么样 | §1 |
+| 一次请求从头到尾怎么走 | §2 |
+| 某个目录/层是干什么的 | §3 |
+| 某个核心机制怎么实现、为什么 | §4(按子系统分) |
+| 模块之间怎么协作、往哪扩展 | §5 |
+| 设计取舍与替代方案代价 | §6 |
+| 这套设计在什么前提下成立、哪里会崩 | §7 |
+| 有多种做法时代码选了哪种 | §8 |
+
+---
+
+## 1. 系统全景
+
+### 1.1 一句话骨架
+
+```
+Gateway 鉴权 → FastAPI api → service(LangGraph 编排)→ tool / repository → MySQL 双库 / Redis / Milvus / Neo4j → 审计落库
+```
+
+展开成进程内分层(`app/`):
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ main.py 装配:lifespan + 中间件栈 + 路由 + 错误 handler │
+├──────────────────────────────────────────────────────────────┤
+│ api/ 路由 · 鉴权工厂 · 中间件(薄,无业务逻辑) │
+├──────────────────────────────────────────────────────────────┤
+│ service/ 编排与业务内核(LangGraph · Tool · 风控引擎) │
+├──────────────┬───────────────────────────────────────────────┤
+│ tool/ │ 纯查询适配器(无业务流程,注册表白名单) │
+│ repository/ │ 数据访问(core_ro 只读 / session / risk) │
+│ gateway/ │ 模拟外部交易系统(唯一允许写 Core 的例外) │
+├──────────────┴───────────────────────────────────────────────┤
+│ model/(占位) config/ utils/(trace·db·response·authz) │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### 1.2 技术选型与硬约束
+
+| 维度 | 选型 | 约束来源 |
+| --- | --- | --- |
+| 运行时 | Python 3.13.14 + FastAPI | `FRAMEWORK.md` §1 已定 |
+| Agent 编排 | LangGraph 1.2.x + langchain-openai | 同上 |
+| 关系库 | MySQL 8.0,**双库** `jinrong_agent` + `jinrong_core` | 同上 |
+| 缓存 | Redis 8.10.1 | 同上 |
+| 向量库 | Milvus Lite + pymilvus 3.0.1,1024 维 | 内存 15.4GB 的妥协 |
+| Embedding | Ollama bge-m3(本地,不出内网) | 数据合规 |
+| 生成 | DeepSeek API | — |
+
+**五条不可触碰的红线**(`MEMORY.md` §3,代码层面均有落点):
+
+1. Core 正式 C1~C5 不可被画像覆盖 → 靠双库物理隔离 + `core_ro` 纯 SELECT 约定(§4.6.1)
+2. 审计表只 INSERT → 仓储层只暴露 `insert_audit_log` / `insert_input_guard_log`(§4.6.4)
+3. 代理人草稿不外发客户 → 无外发通道
+4. 不自动冻户、不自动改风险等级 → 引擎无写 `core_customer_risk` 的路径
+5. **仅 R-02 适当性可阻断交易** → 代码级物理隔离,见 §4.3.3
+
+### 1.3 双库与数据分层
+
+| 层 | 存储 | 内容 | 谁写 |
+| --- | --- | --- | --- |
+| L0 | `jinrong_core` | 客户主档、风评、持仓、流水、交易 | 只读(唯一例外见下) |
+| L3 | `jinrong_agent` | 风控自有监测层 `customer_profile_l3` | 风控引擎 |
+| 会话 | Redis + `jinrong_agent` | 窗口缓存 + 永久消息 | 对话链路 |
+| RAG | Milvus + `data/kb/` | `kb_product_rules` 产品规则 | `scripts/kb/build_kb.py` |
+| 关系 | Neo4j | 客户-产品-代理人图谱 | `scripts/sync/sync_neo4j.py` |
+
+**表规模**:`jinrong_core` 12 张 + `jinrong_agent` 17 张(共用 11 + Agent 专用 6)。
+
+---
+
+## 2. 请求生命周期
+
+### 2.1 中间件栈:实际执行顺序及其推导
+
+`app/main.py` 的注册序是 **audit(第 81 行)在前、trace(第 87 行)在后**。
+
+Starlette 的 `add_middleware()` 内部执行 `self.user_middleware.insert(0, ...)`,而 `build_middleware_stack()` 用 `reversed(middleware)` 由内向外包裹——**数组中越靠前越外层**。
+
+因此实际栈序(外 → 内):
+
+```
+ServerErrorMiddleware → trace_middleware → audit_middleware → ExceptionMiddleware → 路由
+```
+
+**trace 在外层先执行,audit 在内层后执行**。
+
+代码自证:`audit_middleware.py:9` 注释写"注册顺序:trace 中间件之后注册(即执行序在 trace 之内),保证审计时 trace_id/request_id 已绑定"。
+
+> ⚠️ **这是一处隐式依赖**:audit 的正确性完全依赖注册顺序,而顺序由"谁写在文件后面"决定。目前靠注释固化(两处都有),但**没有测试守卫**——若有人调换两个装饰器的位置,审计会静默丢失 trace_id,且不报错。
+
+### 2.2 四个 ID,各管一层
+
+| ID | 层级 | 生成 | 稳定性 |
+| --- | --- | --- | --- |
+| `actor_id` | 人 | JWT `claims.sub`(`deps.py:189`) | 跨请求永不变 |
+| `session_id` | 一次聊天 | `sess-{uuid4().hex[:16]}`(`chat.py:223`) | 跨请求复用 |
+| `trace_id` | 单次请求 | `trc-{uuid4().hex[:16]}`(`trace.py:26`) | 每请求新建 |
+| `request_id` | 单次请求 | `req-{uuid4().hex[:16]}`(`trace.py:54`) | 每请求新建 |
+
+**传播机制是 `contextvars`**(`trace.py:18`)——中间件 `set` 一次,之后任何层直接 `current_trace()` 读取,**不需要把 trace_id 当参数层层传递**。这是"全链路"的实现基础。
+
+`trace_id` 与 `request_id` 分离是 B7 复审 P3-4 的整改结果:前者贯通链路,后者标识单次 HTTP 请求用于幂等对账,注释明确"两者不再互用"(`trace.py:19-20`)。
+
+### 2.3 三条主链路
+
+**(a) 同步对话** `POST /api/chat`
+
+```
+trace 中间件(set trace/request)
+ → audit 中间件
+ → deps.get_auth_context(JWT 验签 + X-Agent-Type 准入)
+ → _guard_request 准入 → 空白 → 限流429 → 注入/超长400
+ → _prepare_turn 会话解析/创建 + 归属断言
+ → memory_service.get_recent(Redis 窗口,miss 回源 MySQL)
+ → agent_service.chat(LangGraph: tool → llm → guard)
+ → session_repository.insert_turn(user+assistant 同事务)
+ → memory_service.append_window
+ → audit 中间件落 http_access
+```
+
+**(b) SSE 流式** `POST /api/chat/stream`
+
+同步部分完全一致,差别在最后:`_events()` 生成器逐块 `yield`,**整轮收完 `done` 才一次性落库**(`chat.py:440` `insert_turn`)。
+
+> **关键细节**:`StreamingResponse` 的生成器是在中间件 `finally` 已经 `reset_trace()` **之后**才被消费的,此时 `current_trace()` 已返回空串。代码在 `chat.py:397` 提前快照:
+> ```python
+> trace_id = current_trace() or "" # 在生成器外面,上下文还在时取值
+> ```
+> 生成器闭包捕获该变量,后续 `insert_turn(..., trace_id=trace_id)` 用快照而非现取。**若按常规写法在生成器里现取,SSE 落库的 trace_id 会全是空串**,消息历史直接断链。
+
+**(c) 交易事件** `POST /api/simulate/trade`
+
+```
+gateway.submit_trade
+ → suitability_check(R-02,唯一阻断点;阻断则 return,交易不落库)
+ → gateway_repository.insert_trade(写 core_trade)
+ → engine.process_trade_event(规则评估 → 预警落库 → L3 → AML)
+```
+
+### 2.4 统一错误体契约
+
+`utils/response.py:28` `error_body()` 固定输出:
+
+```json
+{ "error_code": "...", "message": "...", "trace_id": "...", "request_id": "..." }
+```
+
+- **4xx**:路由 `raise ApiError / PermissionDenied`(`utils/exceptions.py`),由 `register_error_handlers` 转 JSON。
+- **500**:由 `trace_middleware` 兜底(`main.py:102-106`)——异常发生在中间件以内时,Starlette 的 `ServerErrorMiddleware` 生成的 500 响应**不经过用户中间件**,会导致 trace 头丢失;所以这里 catch 后直接产出统一错误体。
+
+---
+
+## 3. 分层职责逐一讲解
+
+### 3.1 `api/` — 路由与接缝
+
+| 文件 | 职责 | 状态 |
+| --- | --- | --- |
+| `deps.py` | 鉴权工厂 `get_auth_context`、准入矩阵、归属断言 | 已实现 |
+| `chat.py` | 对话四端点(同步 + 拉侧三端点 + SSE) | 已实现 |
+| `risk.py` / `simulate.py` | 风控 API / 模拟网关路由 | 已实现 |
+| `audit_middleware.py` | 每请求 `http_access` 审计 | 已实现 |
+| `auth_adapter.py` | 宿主→模块 `AuthContext` 适配 | **预制件,未接线** |
+| `admin.py` / `knowledge.py` | 仅一行 docstring | **空壳** |
+
+**`admin.py` / `knowledge.py` 为什么是空的**:T-21 拍板"一期只做脚本入库,上传/重建端点不做",知识库入库走 `scripts/kb/build_kb.py` 离线脚本。二者未在 `main.py` 中 `include_router`,不暴露任何端点,也不影响启动。注意 `knowledge.py` 空 ≠ RAG 能力缺失——检索能力在 `service/rag_service.py` + `tool/kb_tools.py`,已可用。
+
+**分层纪律**:api 允许调 service;**禁止直连 Milvus / 在路由里写复杂 SQL**。
+
+### 3.2 `service/` — 编排与业务内核
+
+分三类:
+
+1. **对话编排**:`agent_service.py`(LangGraph 图)、`tool_service.py`(Tool 编排)、`memory_service.py`(记忆)
+2. **公共底座**:`auth_service.py`(JWT)、`input_guard.py`(输入防护)、`rag_service.py` / `milvus_service.py` / `embedding.py`(RAG)
+3. **风控业务**:`service/risk/*`(引擎、规则、预警、AML、L3、评分、锁、Redis 网关、对话 Tool)、`service/suitability.py`
+
+### 3.3 `tool/` — 纯查询适配器
+
+**边界定义**:Tool 只做"参数校验 → 调 service/repository → 归一化输出",**不承载业务流程**。
+
+- `core_tools.py`:三个 Core 只读 Tool(`query_customer_profile` / `query_holdings` / `query_recent_trades`)
+- `kb_tools.py`:`search_knowledge`
+- `document_parser.py` / `embedding_tool.py` / `milvus_tool.py`:早期占位 stub(各不足 90 字节),T-21 的实际实现落在 `service/` 层
+
+### 3.4 `repository/` — 数据访问
+
+- `core_ro.py`:`CoreReadOnlyRepository`,**只 SELECT**
+- `session_repository.py`:`agent_session` / `agent_message` / `agent_tool_call`
+- `risk_repository.py`:风控四表 + 两张审计表(仅 INSERT)
+
+### 3.5 `gateway/` — 模拟外部交易系统
+
+**为什么单独成包**:它是全项目**唯一允许写 Core** 的模块(`FRAMEWORK.md` §3 明确列为分层例外)。生产环境由真实交易系统回调替代,**整个包可整体退役**。替换边界就是 `submit_trade(req)` 的契约。
+
+### 3.6 `model/` — 占位层
+
+`schemas.py` 仅 57 字节 docstring;`entities.py` 定义了 31 个 ORM 类但**运行时不使用**——docstring 明确"禁止 `create_all`、仅 IDE 参照、与 SQL 人工同步"。
+
+**实际后果**:风控全程用 `dict` 传参(`insert_alert(alert: dict)` 等)。见 §6.6 的代价分析。
+
+### 3.7 `config/` 与 `utils/`
+
+- `config/settings.py`:`BaseSettings` + `.env`,进程单例 `settings = Settings()`
+- `utils/db.py`:`get_engine(database)` 按库名缓存单例(§4.6.2)
+- `utils/authz.py`:`record_authz_denial`,**下沉到 utils 的原因**是 service 层不得反向依赖 api
+- `utils/trace.py`、`utils/response.py`、`utils/exceptions.py`、`utils/desensitize.py`
+- `utils/logger.py`:**仅一行 docstring,完全占位**——全站日志无配置、无落盘、无结构化(见 §7.3)
+
+---
+
+## 4. 核心子系统深讲
+
+### 4.1 身份与准入体系
+
+**职责与位置**:`api/deps.py` + `service/auth_service.py` + `utils/authz.py`,处于 api 层最前,是所有业务的前置闸门。
+
+**核心实现**(`deps.py:225-279`):
+
+```
+1. Authorization: Bearer 存在 → auth_service.verify_token(验签 + 必填 claims + jti 吊销)
+ · Bearer 为全环境首选,大小写不敏感(auth_header[:7].lower() == "bearer ")
+2. X-Agent-Type 交叉校验 → 缺失 401 / 域外 400 / 不符矩阵 403(均落审计)
+3. 无 Bearer 时:仅当 app_env == "development" 且 jwt_public_key_path 为空
+ → 才走 X-Debug-Role / X-Debug-Actor 兜底(双闸门)
+```
+
+**准入矩阵** `AGENT_ACCESS_MATRIX`(`deps.py:46-57`):
+
+| Agent | token_type | roles |
+| --- | --- | --- |
+| customer | customer | customer |
+| advisor | staff | advisor / compliance / ops |
+| analyst | staff | analyst / compliance |
+| risk | staff, service | risk_officer / risk_manager / service_risk |
+
+**`deny()` 的关键性质**(`deps.py:119-129`):**先落审计再抛异常**。审计下沉到 `utils/authz.record_authz_denial`,双写 `audit_log` + `input_guard_log`,然后 `raise PermissionDenied`。即 403 一律留痕,语义 fail-closed。
+
+#### C5 红线:risk_manager 禁止走对话线(三重保险)
+
+| 层 | 位置 | 行为 |
+| --- | --- | --- |
+| ① 矩阵层 | `deps.py:53-56` | risk 行 roles **含** risk_manager → HTTP 台账放行 |
+| ② 对话入口 | `chat.py:122-124` | `if agent_type == "risk" and "risk_manager" in auth.roles: deny(...)` |
+| ③ Tool 层 | `tool_service.py:138-156` | `assert_tool_access` 只放行 risk_officer,其余 `AUTH_403_SCOPE` |
+
+**为什么分三处而不是一处**:① 是能力声明(这个角色"能进哪个 Agent"),② 是业务线约束(同一 Agent 的两条线权限不同),③ 是纵深兜底(前两层被绕过时仍 fail-closed)。代价是**权限逻辑分散**,改动需三处同步(§7.4)。
+
+#### 权衡
+
+| 决策 | 选了 | 替代方案 | 代价/收益 |
+| --- | --- | --- | --- |
+| 身份来源 | JWT RS256(生产)+ HS256(dev) | 每次查库 session | JWT 免去 DB 往返,但吊销依赖 Redis jti 黑名单,Redis 挂则吊销失效(fail-open) |
+| dev 通道 | debug 头兜底 | 一律强制 JWT | 演示/CI 免签方便;但**双闸门一旦误配(生产设 `app_env=development` 且公钥为空)即放开无签名身份** |
+| 审计失败 | fail-open(仅 warning) | fail-closed | 可用性优先;合规强场景应切 fail-closed,已登记待决 |
+
+### 4.2 对话编排子系统
+
+#### 4.2.1 LangGraph 图结构
+
+`agent_service.py:173-183`:
+
+```python
+def build_graph():
+ graph = StateGraph(ChatState)
+ graph.add_node("tool", tool_node)
+ graph.add_node("llm", llm_node)
+ graph.add_node("guard", guard_node)
+ graph.add_edge(START, "tool")
+ graph.add_edge("tool", "llm")
+ graph.add_edge("llm", "guard")
+ graph.add_edge("guard", END)
+ return graph.compile()
+```
+
+**这是一个线性图,没有条件边**。四 Agent 的差异**不是靠图分支,而是靠数据驱动**:
+
+- `_SYSTEM_PROMPTS`(`:40-56`)按 `agent_type` 固化四种角色边界
+- `_INTENT_KEYWORDS`(`tool_service.py:73-96`)按 agent 分组决定命中哪些 Tool
+- 结果:`analyst` 无关键词 → Tool 节点空转 → 纯 LLM;`risk` 命中风控 Tool;`customer`/`advisor` 命中 Core RO + KB
+
+**`ChatState` 字段**(`:59-77`):`agent_type / history / user_message / messages(Annotated + operator.add) / reply / has_disclaimer / session_id / trace_id / actor / customer_id / tool_results`。`messages` 用 `operator.add` 归并以避免节点覆盖历史。
+
+> **多解说明**:LangGraph 支持 `add_conditional_edges` 做真正的图分支。本项目**选择了数据驱动而非图分支**——理由是四 Agent 共享相同的"取数 → 生成 → 加免责声明"骨架,差异只在提示词与可用工具集,抽成一张图比维护四张子图成本低。代价是**无法表达"风控对话需要额外的复核节点"这类结构性差异**,若将来某 Agent 需要独立节点拓扑,需拆图。
+
+**无 key 降级**(`:148-150`):`if not settings.deepseek_api_key: reply = _degraded_reply(state)`,降级回复携带 Tool 查询摘要并加 `_DEGRADED_PREFIX`,不抛异常。好处是演示链路不断;风险是**生产漏配 key 会"看起来正常"**(§7.3)。
+
+#### 4.2.2 流式执行时序
+
+`stream_chat`(`:275-319`):**Tool 同步跑完,再流式推 LLM 文本**(非交错)。
+
+```
+tool_node(state) → tool_results
+_compose_messages(把 Tool 结果注入上下文)
+llm.stream(messages) → 逐块 yield ("delta", text)
+yield ("done", 完整正文)
+```
+
+**为什么不交错**:Tool 结果要作为 LLM 输入且需落 `agent_tool_call` 留痕,必须同步完成;交错会让 LLM 在 Tool 未返回时"瞎编"——金融场景不可接受。代价是**首字延迟 = Tool 耗时 + LLM 首字**(§7.3)。
+
+#### 4.2.3 Tool 编排
+
+**注册表三层懒加载**(`tool_service.py:115-132`):
+
+```
+core_tools.TOOL_REGISTRY → chat_tools.RISK_TOOL_REGISTRY → kb_tools.KB_TOOL_REGISTRY
+```
+
+懒导入避免模块加载耦合。ToolSpec 字段:`func / description / requires_customer / param_whitelist / int_bounds [/ skip_access_check]`。
+
+**意图识别是关键词匹配,不是 LLM**(`match_intent:100-112`):按 agent 分组遍历 `(tool_name, (kws...))`,`any(k in message for k in kws)`,**命中即停**(单意图)。
+
+> **权衡**:LLM 路由更灵活但增加一次调用延迟与成本,且意图判定不可复现(不利于审计);关键词零延迟、可复现、单测可锁。代价是**用户换说法就漏触**("我的基金" vs "持仓"),且不支持多意图。
+
+**归属校验**(`assert_tool_access:138-156`):
+
+| 角色 | 规则 |
+| --- | --- |
+| risk_officer | 全量放行 |
+| customer | `actor_id == customer_id` |
+| advisor | `core_ro.is_advisor_assigned(actor_id, customer_id)` |
+| 其余 | `AUTH_403_SCOPE` fail-closed |
+
+`skip_access_check=True` **仅 KB Tool**(公开知识无客户对象),开放范围由意图层约束(仅 customer/advisor 配词)。
+
+**入参归一化**(`_normalize_params:162-196`):白名单 + 整数边界钳制,未知键直接报 `TOOL_BAD_PARAM` 而**不静默丢弃**——静默丢弃会让 LLM 以为参数生效了。
+
+**留痕**:`run_tool` 执行后**无条件**调 `_audit_tool_call` 写 `agent_tool_call`(含 `tool_input / tool_output / status / latency_ms`),落库失败降级 warning 不阻塞。
+
+#### 4.2.4 记忆分层
+
+- **Redis 窗口**:key `sess:{agent}:{session_id}:msgs`,`WINDOW_SIZE=20`,`TTL=2h`,`RPUSH + LTRIM` 保最近 20 条
+- **MySQL 权威**:`insert_turn` 落 `agent_message`
+- **回源**:`get_recent` 先 `lrange`,**miss 或异常** fallback `list_messages`
+
+**设计要点**:Redis 是**缓存不是权威**,丢了可重建。这与 Redis 在限流、发布订阅处的 fail-open 口径一致。
+
+#### 4.2.5 输入防护(T-03)
+
+顺序(`chat.py:144-206` `_guard_request`):
+
+```
+Agent 准入 → 空白 → 限流 429 → 注入/超长 400 → 归属 → 会话
+```
+
+- **注入词表 45 条**(`input_guard.py:43-94`;注:旧文档写 42 条,实测 45 条 = 指令覆盖 18 + 角色重置 11 + 系统提示泄露 9 + 越权诱导 7)。短语**精确子串匹配**,不做模糊正则(防误杀"忽略这只股票"这类业务句)。
+- **oversize**:`MESSAGE_MAX_LENGTH = 4000`
+- **限流**:Redis **固定窗口**,actor 级 `ratelimit:{agent}:{actor}`,默认 30/min,**fail-open**
+
+> **为什么限流 fail-open 而鉴权 fail-closed**:限流是可用性保护,不是安全边界;安全边界(鉴权/归属/注入)必须 fail-closed。这个区分是刻意的。代价是 Redis 故障时恶意用户可绕过限流(§7.3)。
+
+> **为什么限流排在内容防护之前**:计数需覆盖全部请求(含将被注入拦截的),让重复攻击者快速收敛到 429。
+
+### 4.3 风控事件线
+
+#### 4.3.1 完整调用链
+
+```
+app/gateway/trade_gateway.py:86 submit_trade
+ ├─ :122 suitability_check ← R-02 唯一阻断点
+ │ └─ blocked → record_suitability_alert → return(交易不落库)
+ ├─ :167 gateway_repository.insert_trade(写 core_trade)
+ └─ :171 engine.process_trade_event(同步)
+ engine.py:114
+ ├─ core.list_trades_range(取当日流水)
+ ├─ :139 run_rules → RISK-001~005 评估
+ ├─ :145 rule_concentration(RISK-006,输入域为持仓,故不并入 run_rules)
+ ├─ :150 record_trade_alerts(聚合落 risk_alert)
+ ├─ :160 upsert_profile_l3(L3 监测层)
+ ├─ :174 match_customer(AML)
+ └─ :177 record_aml_alert
+```
+
+#### 4.3.2 规则与评分
+
+`service/risk/rules.py` 全部为**纯函数**,`run_rules:201` 编排:
+
+| 规则 | 函数 | 阈值来源 |
+| --- | --- | --- |
+| RISK-001 大额 | `rule_large_amount:91` | `risk_large_amount = 500000` |
+| RISK-002 当日累计 | `rule_daily_total:102` | `risk_daily_total = 500000` |
+| RISK-003 频繁交易 | `rule_freq_trade:113` | `risk_freq_count = 3` |
+| RISK-004 试探性 | `rule_probe_pattern:127` | 5 分钟内 3 笔 × 40 万 |
+| RISK-005 小额铺垫 | `rule_small_then_large:145` | 1 万 × 3 笔 |
+| RISK-006 集中度(C4) | `rule_concentration:164` | `concentration_threshold = 0.80` |
+
+**评分是静态映射**(`RULE_SCORES:18`):001/002 = 70、003 = 50、004/005 = 80、006 = 60;`alert_service.py:22 TIER_SCORE` sustainability = 90、aml = 95。
+
+> ⚠️ **`service/risk/scoring.py` 不是实际评分器**——`recompute_customer_score` 直接 `raise NotImplementedError("R-05 dynamic scoring not implemented")`,是签名冻结的预留桩。L3 的 `risk_score` 一期恒为 NULL。
+
+#### 4.3.3 为什么只有 R-02 能阻断
+
+**代码级物理隔离**,两套路径不共用出口:
+
+- R-02 在**网关层**(`submit_trade:126`):`if result.blocked: ...; return {blocked: True}`,此时 `insert_trade`(`:167`)**尚未执行**
+- RISK-001~006 全在 `process_trade_event` 内,命中只调 `record_trade_alerts` 出 `risk_alert(status=pending_review)`;`engine.py:122` 注释明确事件线全程 `return {"blocked": False, ...}`,**引擎无任何阻断出口**
+
+判定权唯一归属 `core_ro.check_suitability` 返回的 `blocked` 字段。
+
+#### 4.3.4 L3 监测层
+
+`customer_profile_l3` 表,字段 `monitor_tier`(normal < watch < high)、`monitor_tags`(追加合并)、`risk_score`(一期 NULL)、`last_alert_id`、`computed_at`。
+
+**并发控制**:进程内锁 + 乐观锁 `update_l3(expected_computed_at)` + 3 次重试。
+
+> ⚠️ **`service/risk/locks.py` 不是分布式锁**:`run_locked:31` 用的是**进程内 `threading.Lock`**,按 key 串行,`LOCK_TIMEOUT_SECONDS = 2.0` 超时降级 `fn(locked=False)`。文件注释明确"多进程部署换 Redis SET NX,接口不变"——**这是 TODO,当前无分布式实现**。单进程演示成立,多实例部署会失效(§7.2)。
+
+#### 4.3.5 C4 / C5 / C6 追加需求
+
+| 需求 | 实现 | 核心逻辑 |
+| --- | --- | --- |
+| C4 集中度 | `rules.py:164` + `engine.py:144` | R4+R5 市值占比 ≥ 0.80;`holdings_truncated` 视同达标(保守口径) |
+| C5 时效升级 | `escalation_service.py:59/84` | 普通 4h/24h、AML 1h/4h;**只写 `payload.escalation_level`,不改 status**;幂等仅升不降 |
+| C6 行为链 | `agent_behavior_service.py:108/289` | A 诱导调仓(赎回 2h 内申购不同产品,3 次)/ B 越权试探(5 次/72h)/ C 越权查询(10 次/24h);出**代理人维度**独立单 |
+
+C6 的单据被 `find_pending_event_alert` 显式排除,避免误并入客户单。
+
+#### 4.3.6 引擎异常补偿
+
+`trade_gateway.py:185-203`:交易已提交但引擎异常 → 记 `risk_engine_error` 审计 + `engine_error=true`,由 `scripts/demo/rebuild_alerts.py` 补偿重放。
+
+> **这是最终一致而非原子**:写 `core_trade`(core 库)与写 `risk_alert`(agent 库)是两个独立事务,双库无法纳入同一事务(§6.2)。
+
+### 4.4 风控对话线
+
+`service/risk/chat_tools.py:328` `RISK_TOOL_REGISTRY` 实际注册 **5 个**:
+
+| Tool | 说明 | requires_customer |
+| --- | --- | --- |
+| `query_overdue_alerts` | 超期未处置预警(C5) | False |
+| `alert_query` | 预警查询(risk_officer 可查全量) | False |
+| `customer_context` | 客户风控上下文 | — |
+| `suitability_check` | 适当性校验(只落 `risk_suitability_log`) | — |
+| `aml_lookup` | AML 名单查询 | — |
+
+(`query_agent_behavior:276` 已定义但未注册,仅由意图层使用。旧文档"四个 Tool"为过时口径。)
+
+**只读边界**:全部无 insert alert / 处置 / 改表动作。事件线独占出预警单、人工处置、AML 扫描。`suitability_check` 复用 `service/suitability.py`,只落审计日志。
+
+### 4.5 RAG 链路
+
+| 环节 | 实现 |
+| --- | --- |
+| Embedding | `service/embedding.py`,Ollama bge-m3,1024 维 |
+| 向量库 | `service/milvus_service.py`,collection `kb_product_rules`,schema 含溯源字段(`source_doc_id` / `source_version` / `effective_date`) |
+| 检索 | `search_kb:119-166`,filter 内建 `effective_date <= today`(只返回已生效文档) |
+| 输出 | `rag_service.search_knowledge:53-77` 返回 `chunks + source_refs`(去重溯源清单) |
+
+**Ollama 失败 = 报错,不降级**(`embedding.py:7-10`):禁止零向量或截断,一律抛 `EmbeddingError`,由 `run_tool` 转 `TOOL_ERROR`。
+
+> **为什么这里反而 fail-closed**:假装"没有结果"会让 LLM 编造产品规则——在金融合规场景下,暴露"查询未完成"比给出错误答案安全得多。与限流的 fail-open 形成对比,说明**降级策略是按"失败后果"逐项决定的,不是全局统一口径**。
+
+### 4.6 数据访问与审计
+
+#### 4.6.1 `core_ro` 只读契约
+
+`CoreReadOnlyRepository`(`core_ro.py:51-498`),绑定 `get_engine(settings.mysql_core_database)`,全部方法用 `self._engine.connect()` 执行 `text()` SELECT。
+
+> ⚠️ **只读靠约定,不靠强制**:类注释声明"仅 SELECT",但**没有 DB 级只读账号,没有 SQL 层拦截**。任何持有 core engine 的代码都能 INSERT。改进见 §7.1。
+
+**`check_suitability`(`:80-199`)—— 全系统唯一的阻断判定核**:
+
+SQL 取回 `core_suitability_rule` 的 `matrix_match_result`(`LEFT JOIN ... ON sr.customer_risk_code = r.risk_code AND sr.product_risk_code = p.min_risk_code`),然后按序判定:
+
+```
+1. 客户/产品缺失 → forbidden / SUIT_NOT_FOUND
+2. risk_is_expired (FM-03) → 阻断 / SUIT_RISK_EXPIRED
+3. professional 投资者 → 豁免 / professional_exempt (JR-AST-PRO)
+4. matrix == forbidden/null → 阻断 / SUIT_RISK_MISMATCH (JR-AST-012)
+5. 否则 → allowed / allowed_with_disclosure
+6. age >= 70 且产品 >= R3 → 阻断 / SUIT_AGE_CONFIRM (FM-01)
+```
+
+结果经 `_suitability_result`(`:201-246`,21 字段)与 `risk_suitability_log` 一一对应。
+
+**矩阵判定是数据驱动的**(查表而非硬编码 if-else),新增风险等级组合只需改 `core_suitability_rule` 表数据。这是阶段一 AL-01~08 的整改成果(旧的 `SUIT-001~008` 纯函数矩阵已退役)。
+
+#### 4.6.2 双库实现与代价
+
+`utils/db.py:23-36` `get_engine(database)` 按库名缓存 `Engine` 单例(字典 + `threading.Lock`)。两个库各自持有独立 engine。
+
+**代价:跨库无法 JOIN**。代码从不跨库联表,而是在 Python 层分别取数合并(如 `submit_trade` 同时调 `core_ro` 与 `risk_repo`)。更进一步,**两库的写操作无法纳入同一事务**——这是 §4.3.6 最终一致性的根因。
+
+#### 4.6.3 会话三表与 `insert_turn`
+
+- `agent_session`:`session_id`(UK) / `trace_id` / `agent_type` / `actor_id` / `customer_id` / `status`(active|closed|blocked) / `metadata`
+- `agent_message`:`session_id` / `trace_id` / `seq_no` / `role` / `content` / `has_disclaimer`
+- `agent_tool_call`:`session_id` / `trace_id` / `tool_name` / `tool_input` / `tool_output` / `status` / `latency_ms`
+
+**`insert_turn`(`:218-275`)同事务落库**:`with self._engine.begin()` 内先取 `MAX(seq_no)+1`,再连续 INSERT user / assistant。杜绝"user 落了 assistant 没落"的半截历史污染 LLM 上下文。
+
+`close_session`(`:78`)用条件 `UPDATE ... WHERE status='active'`,幂等返回 `rowcount > 0`。
+
+> ⚠️ **`agent_message` 的 `(session_id, seq_no)` 是普通索引(`KEY idx_session_seq`)而非唯一索引**,且 `insert_turn` 取 seq 的 SELECT 无 `FOR UPDATE`,并发同会话会**静默产生重号消息**。见 §7.3。
+
+#### 4.6.4 审计表
+
+- `audit_log`:`trace_id` / `event_type` / `agent_type` / `actor_id` / `customer_id` / `rule_id` / `input_summary`(JSON) / `decision` / `risk_score` / `handler_*` / `created_at` —— **只 INSERT**
+- `input_guard_log`:`trace_id` / `session_id` / `agent_type` / `actor_id` / `guard_type`(ENUM 四值) / `raw_excerpt` / `action`(blocked|sanitized|passed)
+
+**"双写"在项目里有两处含义**,注意区分:
+
+1. **越权双写**:`deny` 同时写 `audit_log` + `input_guard_log`(`utils/authz.py:44-76`,**无事务**)
+2. **事件双写**:每笔交易同时落 `risk_alert` + `audit_log`;处置时 `handle_alert_with_audit:309` 状态变更与审计**同事务**(防无痕状态变更)
+
+---
+
+## 5. 模块协作:依赖方向、接口边界与扩展点
+
+### 5.1 依赖方向规则
+
+```
+允许: api → service → tool / repository / model / config
+禁止: api 直连 Milvus;api 写复杂 SQL;tool 写业务流程;repository 写 Core
+例外: app/gateway/ 为模拟外部系统模块,其 gateway_repository 可 INSERT core_trade
+反向依赖禁止:service 不得依赖 api(这就是 utils/authz.py 存在的原因)
+```
+
+### 5.2 接口边界速查
+
+| 边界 | 契约 | 扩展方式 |
+| --- | --- | --- |
+| 宿主 → 模块鉴权 | `auth_adapter.from_host_auth`(**未接线**) | AL-09 合并后接入 |
+| api → service | Pydantic 请求模型 + `AuthContext` | 新增端点只需加路由 |
+| 对话 → Tool | `get_registered_tool` 注册表白名单 | **新增 Tool = 注册表加一项 + 意图词表加词** |
+| 事件 → 规则 | `rules.py` 纯函数 + `RiskThresholds.from_settings` | 阈值改 `.env`,新规则改代码 |
+| 模块 → Core | `core_ro` 纯 SELECT | 只读,不动 |
+| 阻断判定 | `core_suitability_rule` 表数据 | **改数据即可,不改代码** |
+
+### 5.3 主要扩展点
+
+1. **新增对话 Tool**:注册表加一项(core / risk / kb 三选一)+ `_INTENT_KEYWORDS` 配词
+2. **新增风控规则**:`rules.py` 加纯函数 + `run_rules` 编排 + `RULE_SCORES` 映射
+3. **接入动态评分**:实现 `scoring.py:recompute_customer_score`(签名已冻结),同步放开 L3 `risk_score`
+4. **替换真实交易系统**:整体退役 `app/gateway/`,实现 `submit_trade` 契约
+5. **切分布式锁**:`locks.py` 换 Redis SET NX(**接口不变**,注释已预留)
+6. **前端接入**:后端接入面已就绪(方案 B 三端点 + 方案 C SSE),`web/` 待 init
+
+---
+
+## 6. 设计权衡总表
+
+| # | 设计决策 | 采用方案 | 替代方案 | 采用方案的收益 | 替代方案的代价 |
+| --- | --- | --- | --- | --- | --- |
+| 6.1 | 四 Agent 差异表达 | 一张图 + 数据驱动(提示词+意图表) | 四张子图 + 条件边 | 维护成本低、骨架统一 | 无法表达结构性差异(如风控复核节点) |
+| 6.2 | 双库 | `jinrong_core` + `jinrong_agent` 物理隔离 | 单库 + schema 前缀 | Core 红线靠物理隔离兜底 | 跨库不能 JOIN、无分布式事务 → 最终一致 |
+| 6.3 | 意图识别 | 关键词匹配 | LLM function calling | 零延迟、可复现、可单测 | 换说法漏触、不支持多意图 |
+| 6.4 | 流式与 Tool | Tool 同步跑完再流式推 | Tool 与 LLM 交错 | 结果确定性、可留痕 | 首字延迟 = Tool 耗时 + LLM 首字 |
+| 6.5 | 只读保障 | 约定 + 分库 | DB 只读账号 + ORM 强制 | 实现简单 | 无强制,任何持有 engine 的代码都能写 |
+| 6.6 | 数据传参 | dict | Pydantic / TypedDict / ORM | 敏捷,快速迭代 | 无编译期校验,拼写错误运行时才暴露;`entities.py` 31 个类闲置 |
+| 6.7 | 限流降级 | fail-open | fail-closed | Redis 故障不阻断业务 | 故障时恶意用户可绕过 |
+| 6.8 | Embedding 降级 | fail-closed(抛错) | 返回空结果 | 避免 LLM 编造产品规则 | 依赖可用性,Ollama/Milvus 故障直接报错 |
+| 6.9 | 会话落库 | 整轮一次性落(同事务) | 边生成边落 | 无半截历史 | 断连/异常整轮丢失(Tool 留痕仍在) |
+| 6.10 | 锁 | 进程内 `threading.Lock` | Redis SET NX | 零依赖、单进程够用 | 多实例部署失效(TODO 未做) |
+
+---
+
+## 7. 前提假设、适用边界与改进空间
+
+### 7.1 前提假设(不成立则设计失效)
+
+1. **单进程部署**——`locks.py` 进程内锁、`insert_turn` 的 `MAX(seq_no)+1` 竞态(注释承认"并发写锁归后续")均以此为前提
+2. **Core 是模拟库且无人写入**——只读靠约定,无 DB 级账号保护
+3. **用户用语高度收敛**——关键词意图识别的前提
+4. **演示/内网环境**——dev debug 通道、`_degraded_reply` 静默降级均在公网生产下变危险
+5. **SQLite 测试与 MySQL 生产行为一致**——项目大量 Python 端垫片(`_as_date` / `_is_expired` / `str(amount)` / `_jsonable`)源于此
+
+### 7.2 适用边界
+
+| 能力 | 边界 |
+| --- | --- |
+| 分布式锁 | ❌ 多实例部署失效 |
+| 动态风险评分 | ❌ `scoring.py` 是桩,L3 `risk_score` 恒 NULL |
+| 跨库事务 | ❌ 双库最终一致,靠 `rebuild_alerts.py` 补偿 |
+| 知识库管理 API | ❌ 一期只做脚本入库 |
+| 图数据库 | ⚠️ Neo4j 仅由 `sync_neo4j.py` 灌图,运行时未参与主链路 |
+| Milvus Lite | ⚠️ 演示规模,不适合生产 HA |
+| `convert` 交易 | ❌ 网关主动拒绝(DB ENUM 支持,代码收窄) |
+
+### 7.3 主要改进空间(按优先级)
+
+**P0 — 安全与正确**
+
+1. **为 `core_ro` 配 DB 级只读账号**:当前红线仅靠约定,配只读账号是从根基卡死(§4.6.1)
+2. **`agent_message` 并发重号**:索引非唯一 + SELECT 无锁 → 并发同会话静默重号。修法:加 `UNIQUE(session_id, seq_no)` **并同步给 `insert_turn` 加 `IntegrityError` 重试**(只加索引会让并发变 500)
+3. **无 key 降级改显式告警**:`_degraded_reply` 静默返回,生产漏配会"看似正常"
+4. **审计/限流 fail-open 策略可配置**:合规强场景应能切 fail-closed
+
+**P1 — 可维护性**
+
+5. **中间件顺序加测试守卫**:当前靠注释固化,调换装饰器会静默丢 trace_id(§2.1)
+6. **权限逻辑收拢**:C5 红线分散在矩阵 / `_assert_chat_entry` / `assert_tool_access` 三处
+7. **注册表合并**:`TOOL_REGISTRY` 与 `KB_TOOL_REGISTRY` 分裂
+8. **`model/` 层启用**:逐步用 Pydantic / `TypedDict` 约束仓储入参
+
+**P2 — 体验与能力**
+
+9. **意图识别升级**:关键词 → 同义词扩展 + 多意图(谨慎用 LLM,会损失可复现性)
+10. **流式首字优化**:Tool 执行期间先推"查询中"占位帧
+11. **注入词表运营化**:45 条硬编码,宜配置化 + 持续红队补充
+12. **限流改滑动窗口**:当前 INCR + 首命中 EXPIRE 非原子
+13. **日志基建**:`utils/logger.py` 仅一行 docstring,需 basicConfig + 落盘 + trace_id 注入
+
+### 7.4 已知待决事项
+
+- 审计降级 fail-open 是否切 fail-closed(已登记)
+- 进程内锁换 Redis SET NX 的时机(接口已预留)
+- `chat` 链路 `risk_suitability_log.actor_id` 落 `SYSTEM` 待评估
+- 前端 React 多 Agent 入口 `web/` 归属待拍板
+
+---
+
+## 8. 多解处:代码实际采用哪一种
+
+| 议题 | 候选 | 实际采用 | 原因 |
+| --- | --- | --- | --- |
+| 四 Agent 是否各用一张 LangGraph 图 | A 四张子图 / B 一张图数据驱动 | **B** | 骨架相同,差异仅在提示词与工具集 |
+| Tool 与 LLM 是否交错流式 | A 交错 / B Tool 先同步跑完 | **B** | 金融场景不接受 LLM 在 Tool 未返回时编造 |
+| 意图识别用 LLM 还是规则 | A LLM function calling / B 关键词 | **B** | 零延迟、可复现、可单测锁定 |
+| 适当性矩阵硬编码还是查表 | A 硬编码 if-else / B 查 `core_suitability_rule` | **B** | 阶段一 AL-01~08 整改成果,新增组合只改数据 |
+| 失败降级统一 fail-open 还是 fail-closed | A 统一 / B 按失败后果逐项定 | **B** | 限流 fail-open(可用性保护),embedding fail-closed(防编造) |
+| 阻断逻辑放引擎还是网关 | A 引擎统一判定 / B 网关专用分支 | **B** | 只有 R-02 能阻断,物理隔离防误扩权 |
+| 数据传参用 ORM 还是 dict | A ORM / TypedDict / B dict | **B** | 快速迭代优先,代价是失去编译期校验 |
+| 新增/变更双写是否包事务 | A 统一包 / B 按后果定 | **B** | 新增可丢留痕;变更(处置)必须同事务,否则无痕状态变更 |
+
+---
+
+## 9. 附录
+
+### 9.1 关键文件索引
+
+| 关注点 | 文件 |
+| --- | --- |
+| 装配与中间件栈 | `app/main.py` |
+| 鉴权与准入 | `app/api/deps.py`、`app/service/auth_service.py`、`app/utils/authz.py` |
+| 对话四端点 | `app/api/chat.py` |
+| LangGraph 编排 | `app/service/agent_service.py` |
+| Tool 编排 | `app/service/tool_service.py`、`app/tool/core_tools.py`、`app/service/risk/chat_tools.py`、`app/tool/kb_tools.py` |
+| 输入防护 | `app/service/input_guard.py` |
+| 风控事件线 | `app/gateway/trade_gateway.py`、`app/service/risk/engine.py`、`app/service/risk/rules.py` |
+| 适当性判定 | `app/repository/core_ro.py::check_suitability`、`app/service/suitability.py` |
+| 数据访问 | `app/repository/core_ro.py`、`session_repository.py`、`risk_repository.py` |
+| 双 ID 贯通 | `app/utils/trace.py` |
+| 并发原语 | `app/service/risk/locks.py`、`app/service/risk/redis_gateway.py` |
+
+### 9.2 风控阈值清单(`config/settings.py:48-60`)
+
+`risk_large_amount=500000`、`risk_daily_total=500000`、`risk_freq_count=3`、`risk_probe_window_minutes=5` / `risk_probe_count=3` / `risk_probe_amount=400000`、`risk_small_amount=10000` / `risk_small_count=3`、`risk_aml_default_threshold=0.85`、`concentration_threshold=0.80`、`guard_rate_limit_max=30/60s`
+
+### 9.3 表清单
+
+- **共用 11 张**:`agent_session` / `agent_message` / `agent_tool_call` / `audit_log` / `input_guard_log` / `customer_advisor_rel` / `customer_profile_l1` / `customer_profile_l2` / `customer_profile_l3` / `risk_alert` / `risk_suitability_log`
+- **Agent 专用 6 张**:`customer_threshold_config` / `customer_notify_log` / `advisor_draft` / `compliance_hit_log` / `analytics_query_log` / `risk_aml_list`
+- **Core 12 张**:`scripts/core/01-ddl.sql`
+
+### 9.4 文档口径与代码实测不一致处(以代码为准)
+
+| 项 | 旧文档口径 | 代码实测 |
+| --- | --- | --- |
+| 注入词表条数 | 42 条 | **45 条**(`input_guard.py:43-94`) |
+| 风控对话 Tool | 4 个 | **5 个**(`chat_tools.py:328`,另有 1 个定义未注册) |
+| Agent 专用表 | 5 张 | **6 张**(SQL 注释写 5,实建 6) |
+| `scoring.py` | 未明确 | **`NotImplementedError` 预留桩**,非实际评分器 |
+| `locks.py` | 未明确 | **进程内 `threading.Lock`**,非分布式锁 |
+| `convert` 交易 | 未提及 | DB ENUM 支持,**网关主动拒绝** |
+
+---
+
+*本文基于 `risk-control-agent` 分支 `2d0e2fa` 的代码实测撰写。后续改动请先更新 `docs/memory/` 再同步本文。*
diff --git a/docs/项目框架设计/表设计/02-mysql-agent专用.sql b/docs/项目框架设计/表设计/02-mysql-agent专用.sql
index ca3578e..9972839 100644
--- a/docs/项目框架设计/表设计/02-mysql-agent专用.sql
+++ b/docs/项目框架设计/表设计/02-mysql-agent专用.sql
@@ -1,5 +1,5 @@
-- =============================================================================
--- 单 Agent 专用 MySQL(5 张)
+-- 单 Agent 专用 MySQL(6 张)
-- 执行时机:各 Agent 组开发自己的功能时创建(也可一次性全建)
-- 共用底座见 01-mysql-共用底座.sql
-- =============================================================================
diff --git a/tests/test_audit_middleware.py b/tests/test_audit_middleware.py
index 04c3e50..b5ae878 100644
--- a/tests/test_audit_middleware.py
+++ b/tests/test_audit_middleware.py
@@ -60,7 +60,7 @@ def _rows(engine, sql: str, **params) -> list[dict]:
def _http_access(engine) -> list[dict]:
return _rows(
engine,
- "SELECT actor_id, decision, input_summary FROM audit_log WHERE event_type = 'http_access'",
+ "SELECT actor_id, decision, input_summary, trace_id FROM audit_log WHERE event_type = 'http_access'",
)
@@ -202,3 +202,20 @@ def test_guard_log_skipped_for_platform_agent(env):
env["engine"], "SELECT agent_type FROM audit_log WHERE decision = 'forbidden'"
)
assert forbidden and forbidden[0]["agent_type"] == "platform"
+
+
+# ---------- B2 · 中间件执行顺序守卫(T-202) ----------
+
+
+def test_audit_middleware_runs_inside_trace_middleware(env):
+ """守卫:audit 必须在 trace 之内执行,否则 trace_id 静默全空且不报错。
+
+ 调换 main.py 两个装饰器顺序后本测试应变红(确认有效),恢复后转绿。
+ """
+ env["client"].get(
+ "/api/risk/alerts",
+ headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"},
+ )
+ rows = _http_access(env["engine"])
+ assert rows, "应落 http_access 审计"
+ assert rows[-1]["trace_id"], "audit 若先于 trace 执行,此处会静默为空"
diff --git a/tests/test_locks_redis.py b/tests/test_locks_redis.py
new file mode 100644
index 0000000..4e1e6e7
--- /dev/null
+++ b/tests/test_locks_redis.py
@@ -0,0 +1,138 @@
+"""T-201 · Redis 分布式锁双层(Redis 为主、进程内为备)单元测试。
+
+不依赖本机 Redis:所有用例通过 monkeypatch `redis_gateway._gateway` 注入
+可控假网关,覆盖「抢到 / 占用超时 / 不可用降级 / 释放只删自己锁 / 三处 key 前缀」。
+降级路径任意异常(ConnectionError、Fake 缺方法 AttributeError)一律归类为
+unavailable,绝不向上抛——保证 Redis 故障时安全退回进程内锁、业务照常完成。
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.service.risk import locks
+from app.service.risk import redis_gateway
+
+
+class FakeLockGateway:
+ """可控假网关:内存字典模拟 SET NX;raise_on_acquire 模拟 Redis 故障。"""
+
+ def __init__(self, acquire_result: bool = True, raise_on_acquire: Exception | None = None) -> None:
+ self._store: dict[str, str] = {}
+ self.acquire_result = acquire_result
+ self.raise_on_acquire = raise_on_acquire
+ self.captured: list[tuple[str, str, int]] = []
+
+ def acquire_lock(self, key: str, token: str, ttl_seconds: int) -> bool:
+ self.captured.append((key, token, ttl_seconds))
+ if self.raise_on_acquire is not None:
+ raise self.raise_on_acquire
+ if not self.acquire_result:
+ return False
+ if key in self._store:
+ return False
+ self._store[key] = token
+ return True
+
+ def release_lock(self, key: str, token: str) -> bool:
+ if self._store.get(key) == token:
+ del self._store[key]
+ return True
+ return False
+
+
+class NoAcquireGateway:
+ """测试 Fake 缺方法场景:只有 publish 等旧方法,无 acquire_lock/release_lock。"""
+
+ def publish(self, channel, payload):
+ pass
+
+
+@pytest.fixture()
+def gateway(monkeypatch):
+ g = FakeLockGateway()
+ monkeypatch.setattr(redis_gateway, "_gateway", g)
+ return g
+
+
+def test_redis_lock_acquired_runs_locked_true(gateway):
+ """Redis 抢到锁 → fn(locked=True),且 key 带 TTL 与 lock: 前缀。"""
+ seen = {}
+
+ def _fn(locked: bool) -> dict:
+ seen["locked"] = locked
+ return {"ok": True}
+
+ result = locks.run_locked("agg:event:CUST-1:2026-09-09", _fn)
+ assert seen["locked"] is True
+ assert result == {"ok": True}
+ key, _token, ttl = gateway.captured[0]
+ assert key == "lock:agg:event:CUST-1:2026-09-09"
+ assert ttl == locks.LOCK_TTL_SECONDS
+
+
+def test_redis_lock_occupied_times_out(gateway, monkeypatch):
+ """锁被占用(抢不到)→ 等待超时降级 fn(locked=False),不抛异常。"""
+ gateway.acquire_result = False
+ monkeypatch.setattr(locks, "LOCK_TIMEOUT_SECONDS", 0.2)
+ monkeypatch.setattr(locks, "_RETRY_INTERVAL", 0.02)
+ seen = {}
+
+ def _fn(locked: bool) -> str:
+ seen["locked"] = locked
+ return "done"
+
+ assert locks.run_locked("agg:event:CUST-2:2026-09-09", _fn) == "done"
+ assert seen["locked"] is False
+
+
+def test_redis_connection_error_falls_back_process_lock(gateway):
+ """Redis 抛 ConnectionError → 归类 unavailable → 退回进程内锁,业务完成。"""
+ gateway.raise_on_acquire = ConnectionError("redis down")
+ seen = {}
+
+ def _fn(locked: bool) -> str:
+ seen["locked"] = locked
+ return "business-done"
+
+ assert locks.run_locked("agg:suitability:CUST-3:P-9:2026-09-09", _fn) == "business-done"
+ assert seen["locked"] is True # 进程内锁拿到 → locked=True
+
+
+def test_redis_missing_method_attribute_error_falls_back(gateway, monkeypatch):
+ """Fake 缺 acquire_lock(AttributeError)→ 归类 unavailable,测试不红。"""
+ monkeypatch.setattr(redis_gateway, "_gateway", NoAcquireGateway())
+ seen = {}
+
+ def _fn(locked: bool) -> str:
+ seen["locked"] = locked
+ return "ok"
+
+ assert locks.run_locked("l3:CUST-4", _fn) == "ok"
+ assert seen["locked"] is True
+
+
+def test_release_only_owns_lock():
+ """释放只删自己的锁:错误 token 返回 False 且锁仍在,正确 token 才删。"""
+ g = FakeLockGateway()
+ assert g.acquire_lock("lock:k", "tokA", 30) is True
+ assert g.release_lock("lock:k", "tokB") is False
+ assert "lock:k" in g._store # 锁仍在
+ assert g.release_lock("lock:k", "tokA") is True
+ assert "lock:k" not in g._store
+
+
+def test_three_call_site_keys_have_lock_prefix(gateway):
+ """三处调用点 key 形态全覆盖:agg:event: / agg:suitability: / l3: 均带 lock: 前缀。"""
+ keys = [
+ "agg:event:CUST-X:2026-09-09",
+ "agg:suitability:CUST-X:P-Y:2026-09-09",
+ "l3:CUST-X",
+ ]
+ for k in keys:
+ locks.run_locked(k, lambda locked: True)
+ redis_keys = [c[0] for c in gateway.captured]
+ assert all(rk.startswith("lock:") for rk in redis_keys)
+ assert "lock:agg:event:CUST-X:2026-09-09" in redis_keys
+ assert "lock:agg:suitability:CUST-X:P-Y:2026-09-09" in redis_keys
+ assert "lock:l3:CUST-X" in redis_keys