一、流程文档(按 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)
30 KiB
XingHuo 架构改进方案评审 · 问题清单与方案对比
文档性质:送审材料。供另一个 AI 在全新上下文中独立审核。 代码基线:分支
risk-control-agent,HEAD2d0e2fa(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 |
三条最需要优先处理的(按"后果严重性 × 触发概率"排序):
- B1
agent_message的(session_id, seq_no)是普通索引而非唯一索引 → 并发同会话会静默产生重号消息(本轮新发现,此前未记录) - C1
core_ro只读纯靠约定,无 DB 级账号保护 → 项目五条红线之一是"Core C1~C5 不可被覆盖",当前无任何强制手段 - 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 并发会静默产生重号消息(本轮新发现)
证据链:
session_repository.py:241-252:insert_turn在事务内取MAX(seq_no)+1- 该事务的 SELECT 是非锁定读,MySQL InnoDB 默认 REPEATABLE READ 下两个并发事务仍可读到相同的
MAX值 01-mysql-共用底座.sql中agent_message只有KEY idx_session_seq (session_id, seq_no)—— 普通索引,非 UNIQUE- 代码注释自陈:"并发同会话不重号(并发写锁归后续)"——承认未做完
后果:两个事务算出同一个 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:134next_seq_no()用非事务的.connect()取号,:142insert_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(模拟真实交易系统)有这个能力,网关主动收窄了。
为什么当前不支持(代码层面的硬约束):
- 请求体
req只有{customer_id, product_id, trade_type, amount}—— 单个product_id check_suitability(customer_id, product_id)只接受一个产品 —— 转换需要校验转入那只是否匹配客户风险等级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 里写死: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 索引)
- 先执行
SELECT session_id, seq_no, COUNT(*) FROM agent_message GROUP BY session_id, seq_no HAVING COUNT(*) > 1确认历史无重号 - 有重号 → 先清洗再建索引
- 建索引后跑全量 pytest
- 并发用例:模拟同会话并发
insert_turn,确认抛异常而非静默重号 - 验证
list_messages_page分页不受影响
若改 B2(加中间件顺序测试)
- 新增测试:发起任意请求 → 断言
audit_log中该行trace_id非空 - 反向验证:临时调换装饰器顺序 → 测试应变红(确认测试有效)
- 恢复顺序 → 测试转绿
若改 B5(限流 EXPIRE)
- 单测覆盖:首次请求后键存在且带 TTL
- 模拟 EXPIRE 失败 → 确认不产生无 TTL 键
若改 C1(只读账号)
- 拆分连接串:
core_ro用只读账号,gateway_repository用可写账号 - 验证
submit_trade仍能写core_trade - 验证
core_ro所有查询正常 - 尝试用
core_ro执行 INSERT → 应被数据库拒绝 - 全量 pytest + uvicorn 冒烟三端点
若改 C4(启动告警)
- 清空
.env的DEEPSEEK_API_KEY→ 启动应打 WARNING - 填回 → 无告警
若改 D1(日志配置)
- 验证日志落盘
- 验证 trace_id 出现在每条记录中
- 验证并发请求下 trace_id 不串号(关键:contextvar 传播)
若改 F1(支持 convert)
- 接口协议变更需先经确认
- 单测:convert 正常路径、转入产品适当性不匹配路径、持仓不足路径
- 引擎回归:确认转换对 RISK-001/002/003 的计数影响符合产品定义
- 审计回归:确认 convert 落审计的
decision取值正确 - 前端/演示脚本同步
4.3 最小回归命令
# 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 待确认事项是否有遗漏(尤其是需要业务方而非技术方决策的)
结论要求
- 明确给出:可实施 / 需修改后实施 / 不建议实施
- 若发现本文事实性错误,请直接指出并给出正确结论