From 037ce7edca20afb6c422fa265754ec37f552c61e Mon Sep 17 00:00:00 2001 From: YUAN Date: Wed, 9 Sep 2026 18:10:03 +0800 Subject: [PATCH] =?UTF-8?q?docs(=E6=9E=B6=E6=9E=84=E6=94=B9=E8=BF=9B):=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20PRD/=E5=BC=80=E5=8F=91=E8=AE=A1=E5=88=92/T?= =?UTF-8?q?ODO/=E4=BA=A4=E6=8E=A5=E6=96=87=E6=A1=A3=EF=BC=8C=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E6=97=A0=E5=AF=86=E9=92=A5=E5=91=8A=E8=AD=A6=E4=B8=8E?= =?UTF-8?q?=20Redis=20=E5=88=86=E5=B8=83=E5=BC=8F=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、流程文档(按 AIcoding 六步落地,供新会话从交接文档开工) - 新增 docs/PRD/PRD-架构改进与稳定性加固.md:6 条 FR(文档勘误、非缺陷说明、 无密钥启动告警、审计失败告警、Redis 分布式锁、中间件顺序测试) - 新增 docs/项目框架设计/改进方案评审-问题清单与对比.md:24 项问题分档 A~G, 经两轮独立 AI 评审,无阻断级错误 - 新增 docs/项目框架设计/开发计划-架构改进.md:HOW 层设计,含合并前只做低风险 11 项的批次策略 - 新增 docs/项目框架设计/TODO-架构改进.md:T-101~T-109、T-201~T-202 可勾选项 - 新增 docs/交接文档-架构改进.md:自包含交接入口,hy3 新会话可直接开工 - 新增 docs/项目框架设计/架构设计说明书.md:按模块/分层逐一讲解的全量架构说明 二、代码改动(T-107/108/109、T-201.1、T-201.2) - app/main.py:启动时 DEEPSEEK_API_KEY 缺失告警,明确告知将走降级回复 - app/utils/authz.py:越权审计失败日志补 trace_id,便于串联全链路 - app/api/audit_middleware.py:审计失败日志补 status/path/request_id - app/service/risk/redis_gateway.py:新增 acquire_lock(SET NX EX)与 release_lock(Lua 原子释放,只删自己的锁) - app/service/risk/locks.py:run_locked 改为双层锁,Redis 为主、进程内锁为备; Redis 超时沿用 fn(locked=False) 降级语义,Redis 不可用(含测试 Fake 缺方法的 AttributeError)安全退回进程内锁,绝不抛异常 三、文档勘误(A1/A2/A3) - MEMORY.md:文件数 42→45、Tools 4→5 - 02-mysql-agent专用.sql:会话表 5→6 - 架构设计-风控模块.md:同步更正 四、测试 - 新增 tests/test_locks_redis.py:覆盖抢锁成功、占用超时、Redis 故障降级、 Fake 缺方法降级、只删自己锁、三处调用点 key 前缀 - tests/test_audit_middleware.py:补充告警字段断言 - 全量 pytest 510 passed(原基线 503) --- app/api/audit_middleware.py | 8 +- app/main.py | 7 + app/service/risk/locks.py | 66 +- app/service/risk/redis_gateway.py | 17 + app/utils/authz.py | 3 +- docs/PRD/PRD-架构改进与稳定性加固.md | 210 ++++++ docs/memory/MEMORY.md | 4 +- docs/交接文档-架构改进.md | 241 +++++++ docs/项目框架设计/TODO-架构改进.md | 246 +++++++ docs/项目框架设计/开发计划-架构改进.md | 477 ++++++++++++ .../改进方案评审-问题清单与对比.md | 488 +++++++++++++ docs/项目框架设计/架构设计-风控模块.md | 14 +- docs/项目框架设计/架构设计说明书.md | 682 ++++++++++++++++++ .../表设计/02-mysql-agent专用.sql | 2 +- tests/test_audit_middleware.py | 19 +- tests/test_locks_redis.py | 138 ++++ 16 files changed, 2607 insertions(+), 15 deletions(-) create mode 100644 docs/PRD/PRD-架构改进与稳定性加固.md create mode 100644 docs/交接文档-架构改进.md create mode 100644 docs/项目框架设计/TODO-架构改进.md create mode 100644 docs/项目框架设计/开发计划-架构改进.md create mode 100644 docs/项目框架设计/改进方案评审-问题清单与对比.md create mode 100644 docs/项目框架设计/架构设计说明书.md create mode 100644 tests/test_locks_redis.py 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