基金转换 T-8:规则引擎改造(_amount_view 去重视图 + process_convert_event)

一次转换落两条流水(转出 redeem + 转入 subscribe,同 convert_group_id),
金额聚合类规则若两条都算会翻倍报假预警,故按「逐笔 / 聚合」拆成两个视图。

- rules.py:新增 _amount_view —— 同组内只保留 redeem 那条(无 convert_group_id
  的交易恒等通过、组内无 redeem 保首条、绝不删行);run_rules 双视图分流:
  RISK-001/003/004 用全量 eligible(逐笔判定),RISK-002/005 走金额视图去重。
- alert_service.py:抽出公开 build_trade_event(事件体结构唯一定义),
  record_trade_alerts 新增可选 events 参数;缺省 None 退化为单条,
  既有调用零改动。新建单落 payload.events 全部,聚合追加只追首条(只认转出端)。
- engine.py:抽 _run 共用实现;process_trade_event 变薄封装(签名与行为不变);
  新增 process_convert_event(out_trade, in_trade, ...) 与 _notify_error_hook
  (hook 自身异常吞掉,原始异常照常上抛)。

实质影响:阶段 1.5 从「ImportError 静默跳过」变为「真跑」——T-7 部署时
process_convert_event 不存在,T-8 落地后同一笔转换会真实出单 + 写 L3 + 落审计;
T-7 真库脚本复跑仍 35/35,无连带破坏。

验证:新增 tests/test_convert_engine.py(15 用例)+ test_convert_service.py
接线回归 1 条;pytest 672 passed / 3 skipped(基线 656 +16,零回归);
新增 scripts/dev/verify_convert_engine.py 真 MySQL 验证 31/31;
突变验证(关掉去重视图 → 4 条变红)确认用例非假绿。
This commit is contained in:
2026-09-10 17:34:35 +08:00
parent 318cb39a1f
commit 7ad1c8204d
10 changed files with 1061 additions and 51 deletions
+49
View File
@@ -225,3 +225,52 @@ SOP A-4:CUST-9527 / PROD-510300 / subscribe / 1000 **连发 4 笔**:
3. **交接文档 §B 顶部加 skill 指针**:本线设计方法论已固化,避免重复维护。
**效果**:方法论跨项目可复用(新项目直接加载 skill);本项目记忆回到「状态 + 红线 + 口径」本职。
---
## 深夜 · 基金转换线 T-8(规则引擎改造)完成
**范围**:开发计划 §7.1 —— `rules._amount_view` + `engine.process_convert_event` + `alert_service.events`(并行组 B)。
### 改码三处(均在代码注释留痕)
- `app/service/risk/rules.py`:+`_amount_view(trades)`(同 `convert_group_id` 组内只留 `redeem`;**无 gid 恒等通过**;组内无 redeem 保首条);`run_rules` **双视图分流** —— `eligible`(全量)供 RISK-001/003/004,`_amount_view(eligible)` 供 RISK-002/005。
- `app/service/risk/alert_service.py`:+**公开** `build_trade_event(trade, hits)`(结构定义唯一副本);`record_trade_alerts(..., events=None)` 缺省退化为单条,既有调用零改动;新建单落 `payload.events` 全部,**聚合追加只追首条**(与架构 §5.4「只认转出端」同口径)。
- `app/service/risk/engine.py`:抽 `_run()` 共用实现;`process_trade_event` 变薄封装(**签名与行为不变**);新增 `process_convert_event(out_trade, in_trade, *, core_ro, risk_repo, thresholds, on_error_hook=None)` + `_notify_error_hook`(hook 自身异常吞掉、原始异常照常上抛)。
### ⭐ 实质影响:阶段 1.5 从「静默跳过」变「真跑」
T-7 落地时 `process_convert_event` 不存在 → `_run_engine` 走 `ImportError` 分支**跳过**;
**T-8 落地后同一笔转换会真实出单 + 写 `customer_profile_l3` + 落审计**。
→ `verify_convert_service.py` 的执行语义随之变化,复跑仍 **35/35 绿**(无连带破坏)。
### 验证
- 新增 `tests/test_convert_engine.py` **15 用例**;`test_convert_service.py` **+1 条接线回归**(大额转换真出单,防 `_run_engine` 退回跳过)。
- `pytest -q` → **672 passed / 3 skipped**(基线 656 **+16**,零回归)。
- **突变验证(防假绿)**:临时把 `amount_view = eligible` → **4 条变红**,其中 `detail` 直接暴露 `当日申赎累计 599000 元(共 2 笔)` 的翻倍错误;恢复后复绿。
- **新增 `scripts/dev/verify_convert_engine.py` 真库 31/31**(A 引擎真跑出单 / B **RISK-002 不翻倍夹逼**:单条 512000 < 800000 < 两条之和 1019975.33 / C 不删行 + DECIMAL 零漂移 / D 幂等重试不重复出单 / E 无命中只落 pass 审计 / F 六表零残留)。
### 真库脚本两条踩坑(与 T-7 同款 + 新一条,供后续 `verify_*.py` 参考)
1. **`id_factory` 必须注入**:默认生成 `CNV-<日期>-<uuid>`,与清理口径 `LIKE 'CNV-T8M%'` 不匹配 → 重跑遗留行撞 `uk_group`。注入 `_t8m_id` 并把清理条件补 `client_request_id LIKE 'T8M-%'` 兜底。
2. **`core_trade.trade_type` 在 MySQL 是 ENUM,`ORDER BY` 按定义序**(实测 `[subscribe, redeem]`)→ 断言改用 `{trade_type: row}` 字典定位,不依赖排序。
### 发现的既有行为(不在本任务范围,**未顺手改**)
> **更正(同日)**:初次记录误写成「RISK-001 实为当日累计口径」,**错误**。RISK-001 与 RISK-002
> 是两条独立规则——前者**单笔**(`rules.py:128-133`)、后者**当日累计**(`:137-145`)。T-8 只切后者。
真实根因在**引擎入参范围**:`process_trade_event` 拉**当日全量**流水重跑规则
(`engine.py` 的 `list_trades_range(customer, day_start, day_end)`)→ **触发这笔**与**命中那笔**
可以不是同一笔:真库 E 组实测,同客户同日先有一笔大额转换时,后续一笔 1000 元小额交易也带出 RISK-001。
既有聚合逻辑(同客户同日一张 pending 单)兜住了重复出单,属**既有设计**,T-8 不改变也不修。
### 文档回写
开发计划(§0 速览标 ✅ + 测试基线改 672 · **§7.1 DoD 全勾 + 新增执行记录 + 3 条实施级收敛 + 2 条脚本坑**)·
根 `交接文档.md` §B → **v1.9**(§0 导航 · §B 头部 · §B.1 状态与代码改动 · **新增 §B.6.1 T-8 小节** · §B.6 任务树与基线)·
`docs/memory/{MEMORY,TODO}` · 本条。
**下一步 = T-9(`api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 · 关键路径)/
T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。
+3 -3
View File
@@ -11,7 +11,7 @@
**当前进度:** 需求与表设计已定 · **风控模块 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`,本地跟踪已建立**;**架构改进与稳定性加固 T-101~T-202 已于 2026-09-09 完成并推送 origin/risk-control-agent(2d0e2fa..f733fc2 快进,pytest 510 绿,较 503 基线 +7 例:T-201.3 双层锁 6 例 + T-202 trace 顺序守卫 1 例),含 Redis 双层分布式锁 T-201 与审计中间件 trace 顺序守卫 T-202,均为文档/告警/锁原语修正、接口契约与表结构零变更;远程已提示走 PR 合并 main;**合并(risk-control-agent → main)由合并执行人负责,不归用户管**——AI 只负责把分支推到远程 + 更新文档,不得主动发起 PR / 执行合并,移交执行人按《合并注意事项-风控模块并入main.md》操作。** 旧文档中的 `feature/risk` 为过时口径)。**
**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.1 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正**)→ 架构 **v1.0 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b 已于 2026-09-10 完成(609 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致)**,**下一步 = T-3(并行组 A:T-3 / T-4 / T-5 可同时开工)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:项目根 `交接文档.md` §B(三线合并版唯一入口)**。合规基准 = 证监会公告〔2025〕22 号。
**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.1 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正**)→ 架构 **v1.0 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b + T-3 + T-4 + T-5 + T-6 + T-7 + T-8 已于 2026-09-10 完成(672 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致;T-6 真库 24/24;T-7 真库 35/35;T-8 真库 31/31)**,**下一步 = T-9(`api/simulate.py` + `trade_gateway` 分派 · 关键路径)/ T-11(汇总去重 · 依赖 T-8 已解锁)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:项目根 `交接文档.md` §B(三线合并版唯一入口)**。合规基准 = 证监会公告〔2025〕22 号。
**仓库地图:**
@@ -70,7 +70,7 @@
**下一步开发(见 TODO):** **模块侧交付完毕(2026-09-07:全量 pytest 482 绿 + 接口实调验收通过——suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403 契约零偏差;`risk-m1~m4` tag 齐)。合并 main 已移交合并执行人,操作手册《docs/项目框架设计/合并注意事项-风控模块并入main.md》(含基底锁定/20 冲突裁决/14 静默文件/三硬伤/合并后必测,实测数据编制)。模块侧开放项:chat 链路 risk_suitability_log.actor_id 落 SYSTEM 待评估 / 前端 React 多 Agent 入口(`web/` 未 init,归属待拍板)。**演示走查按 `docs/项目框架设计/演示SOP-风控模块.md`(debug 头通道仍有效;JWT 通道签发用 `scripts/dev/issue_dev_token.py`;演示库已按 AL-08 expires_at 新口径重灌)。知识库入库:`python scripts/kb/build_kb.py`(先启 Ollama;**Milvus 数据路径必须纯英文**——faiss 不支持中文路径,本机 .env 已配 C:/Users/YUAN/.jinrong/milvus/)。
**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0 / T-0b / T-1 / T-2 / T-2b 已完成,下一步 = T-3**;设计 + 开发计划均已闭环,入口 项目根 `交接文档.md` §B);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 只是远程跟踪引用失效,`git fetch` 即恢复,非远程分支被删)。入口 **项目根 `交接文档.md` §C**。
**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0~T-8 已完成、672 绿,下一步 = T-9(API+网关分派)/ T-11(汇总去重)**;设计 + 开发计划均已闭环,入口 项目根 `交接文档.md` §B);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 只是远程跟踪引用失效,`git fetch` 即恢复,非远程分支被删)。入口 **项目根 `交接文档.md` §C**。
**禁止(改代码前必记):** Core 正式 C1~C5 不可被画像覆盖 · 审计表只 INSERT · 代理人草稿不外发 · 仅 R-02 可阻断交易 · 四 Agent 不互调 LLM。
@@ -200,6 +200,6 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md
3. 是否需 customer_id 归属与 JWT RBAC?
4. Core 是模拟库只读还是 agent 库读写?
5. 如何验证?(`python -m pytest` 全量(当前 **516 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列)
6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0 / T-0b / T-1 / T-2 / T-2b 已完成(609 passed),下一步 = T-3**)——动代码前先确认自己属于哪条线,别混淆前置条件。
6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0~T-8 已完成(672 passed),下一步 = T-9 / T-11**)——动代码前先确认自己属于哪条线,别混淆前置条件。
大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。
+3 -2
View File
@@ -7,7 +7,7 @@
**阶段一 AL-01~AL-08 与阶段二 C4~C6 均已完成(2026-09-07)**:全量 pytest **482 passed 0 failed 0 skipped**(真库集成)✓ · uvicorn 冒烟三端点 ✓ · 接口实调验收 ✓(2026-09-07:suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403,契约零偏差)· risk-m1~m4 tag 齐。**合并 main 已移交合并执行人**(操作手册:《docs/项目框架设计/合并注意事项-风控模块并入main.md》,随分支上传),后续模块侧待办见下方。
**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.1** + 架构 **v1.0** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 + T-0b + T-1 + T-2 + T-2b 均于 2026-09-10 完成**(**609 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**),**下一步 = T-3(并行组 A:T-3 / T-4 / T-5 可同时开工)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。
**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.1** + 架构 **v1.0** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 + T-0b + T-1 + T-2 + T-2b + T-3 + T-4 + T-5 + T-6 + T-7 + T-8 均已于 2026-09-10 完成**(**672 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**、T-6 真库 **24/24**、T-7 真库 **35/35**、**T-8 真库 31/31**),**下一步 = T-9(`api/simulate.py` + `trade_gateway` 分派 · 关键路径)/ T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。
### 基金转换线待办(推荐顺序)
@@ -16,7 +16,8 @@
- [x] **【T-0b · 阻断前置】DB 账号分离(D20)** —— **完成(2026-09-10)**:`scripts/core/00-grant.sql` 新建(3 账号逐表授权,**不进 reset.ps1**);`settings.py` +6 项;`db.py` 改 `get_engine(db, role)` + `_resolve_credentials`(缓存键 `(db, role)`,未配置回退 `mysql_user`);`core_ro`→`ro` · `gateway_repository`→`rw` · `risk_repository`/`session_repository`→`rw` 显式;`conftest.py` 4 处→`admin`(R-e)。**两处口径修正**:① `audit_log` 实授 **`SELECT, INSERT`**(字面「只授 INSERT」会剥夺读,致 `has_engine_error_audit`/`list_audit_events` 失权)② conftest 必须 admin。**遗留环境操作**:`00-grant.sql` 需管理员执行 + 写 `.env`,未执行时 3 条权限断言自动 skip
- [x] **【T-1 · 第 1 批】DDL + 种子 + sqlite 同步** —— **完成(2026-09-10)**:`scripts/core/01-ddl.sql` 新建 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail` + `core_trade` 加 `convert_group_id`+索引 + `core_product` 加 8 列(`subscribe_fee_rate` 等)+ `fee_rate` 补 COMMENT;**新增 `07-seed-fee-rule.sql`**(14 产品 × 5 档,按 22 号文 §10)/ **`08-seed-share-lot.sql`**(58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty,CUST-9527 跨批次)/ **`09-seed-org.sql`**(管理人 + TA + 申购费率 + 最低持有余额);`reset.ps1` 追加 07/08/09;`02-mysql-agent专用.sql` 追加 `risk_convert_detail`(status ENUM 建表即 5 值);`tests/_ddl.py` 同步 4 表 + `REQUIRED_CONVERT_TABLES` 门禁。**验证**:新增 `scripts/dev/verify_convert_seed.py`(pymysql 等价 reset 流程 + 8 条断言)→ **8/8 PASS**;`pytest -q` → **516 passed / 3 skipped(零回归)**。**3 点需注意**:① mysql 不在 PATH → 用该脚本替代 reset.ps1;② `core_fee_rule` 读取走只读账号(T-6 遵守);③ ~~`PROD-005827` 费率分类口径差异(`mixed` vs 主动偏股)待裁定~~ → **已裁定并修正(PRD v0.9.1)**:`mixed` 归位 `0.0050`(其他混合型),主示例转入方改真主动偏股 `PROD-003095`,`09-seed-org.sql` 升 **v1.1** 按「管理人全产品线」重排,并新增断言 ⑧ 机器化卡口
- [x] **【T-2 · 第 2 批】`service/convert/` 纯函数包** —— **完成(2026-09-10)**:新建 `app/service/convert/` **7 文件**(`__init__` / `types`(`Lot`/`FeeRule`/`LotAllocation`/`PlanResult` frozen dataclass + 3 个归一工具)/ `calc`(`plan_lots`/`lot_amount`/`lot_fee`/`convert_amount`/`in_qty`/`rounding_diff`/`diff_fee`/`hold_days`/`ensure_batch_limit`)/ `fee`(`pick_fee_rate` 左闭右开)/ `nav`(`ensure_nav_ready`→503 / `is_stale`)/ `lot_bootstrap`(D18 单点,`crc32` 确定性偏移)/ `errors`(`ConvertError` + 11 子类))。**新增 `tests/test_convert_calc.py` 93 用例**(12 类:精度 HALF_UP 反向自证 / 分档边界 6-7-29-30-179-180-364-365 / FIFO 含同 `confirmed_at` tiebreak / 跨批计费 / 双口径 252.40 vs 253.91 / 强制全转与强制赎回 / 恰好等于阈值不触发 / **零剩余不触发(新裁定 R-h)** / PRD §5.3 全链自证 / T+1 起算 / 净值 503 与 stale 分家 / D18 确定性 / §8.3 错误码 / **纯函数零 IO 依赖断言**)。**验证**:`pytest -q` → **609 passed / 3 skipped(+93,零回归)**;`calc_convert_demo.py` → **15/15 与 PRD §5.3 一致**(退出码 0)
- [ ] **【T-3 起】** 仓储与锁(并行组 A:T-3 / T-4 / T-5 可同时开工)→ T-6 / T-7 → T-8 / T-9 / T-11 → **T-10 高风险单列** → T-12 → **T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10)
- [x] **【T-3 ~ T-8 已完成】** 仓储与锁(并行组 A:T-3 / T-4 / T-5 ✅)→ T-6 ✅(阶段一事务 · 真库 24/24)→ T-7 ✅(八步编排 · 真库 35/35)→ **T-8 ✅(规则引擎改造 · 真库 31/31;⭐ 阶段 1.5 从「跳过」变「真跑」)**
- [ ] **【T-9 起】剩余任务**:T-9(`api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派)→ T-11(`core_tools` / `sum_trades_on_date` 汇总去重)→ **T-10 高风险单列** → T-12 → **T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10)
> **第 0~2 批结果(2026-09-10)**:基线 **510 passed** → 批 0 后 **516 passed / 3 skipped**(+3 T-0 用例 +3 T-0b 引擎用例)→ 批 1(T-1)后**仍 516 passed / 3 skipped**(只加表与种子,未加用例 → **零回归**);**T-1 数据层断言 8/8 PASS**(含新增断言 ⑧:费率档 ↔ `product_type` 匹配,越档即 FAIL)→ 批 2(T-2 + T-2b)后 **609 passed / 3 skipped**(**+93 纯函数用例**,零回归);T-2b 实算脚本 15/15 与 PRD §5.3 一致(退出码 0)。
> **下一步 = T-3**(`core_ro` 五个新方法:`get_nav_as_of` / `get_redeem_fee_rules` / `list_share_lots` / `sum_remain_qty` / `get_holding` + 新增 `app/repository/share_lot_repository.py`;DoD 见开发计划 §5.1)。**并行组 A 的 T-3 / T-4 / T-5 可同时开工**。
@@ -115,26 +115,26 @@ T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算
| --- | --- | --- | --- | --- |
| **第 0 批 · 门禁** | **T-0** ✅ | sqlite/MySQL 结构对齐(`core_holding` 列名 + PK、补 `core_product_nav`)+ 建库自校验 + 门禁用例 —— **2026-09-10 完成** | 无 | **低(但阻断)** |
| | **T-0b** ✅ | DB 账号分离(D20):`00-grant.sql` + `settings` 3 组账号 + `get_engine(db, role)` + 3 个权限断言 —— **2026-09-10 完成** | 无(可并行 T-0) | **低(但阻断)** |
| **第 1 批 · 数据与纯函数** | T-1 | MySQL DDL + 3 个种子 + `reset.ps1` + sqlite DDL 同步 | **T-0 + T-0b 双绿** | 低 |
| | T-2 | `service/convert/` 纯函数包(`types`/`calc`/`fee`/`nav`/`lot_bootstrap`/`errors`) | 无 | 低 |
| | T-2b | `calc_convert_demo.py` 实算 + 回填 PRD §5.3 与验收断言 | T-2 | 低 |
| **第 2 批 · 仓储与锁** | T-3 | `core_ro` 五个新方法 + `share_lot_repository` | T-1 | 低 |
| | T-4 | `convert_repository`(占位/回写/查询/清理) | T-1 | 低 |
| | T-5 | `locks.try_lock` + 单测 | 无 | 低 |
| **第 3 批 · 事务与编排** | T-6 | `convert_core_repository.apply_convert`(阶段一单事务) | T-1/T-3 | **高(方言 + 并发)** |
| | T-7 | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5) | T-2~T-6 | **高(关键路径)** |
| **第 4 批 · 引擎与网关** | T-8 | `_amount_view` + `engine.process_convert_event` + `alert_service.events` | 无(可与 T-2 并行) | 中 |
| **第 1 批 · 数据与纯函数** | **T-1** ✅ | MySQL DDL + 3 个种子 + `reset.ps1` + sqlite DDL 同步 —— **2026-09-10 完成(断言 8/8)** | **T-0 + T-0b 双绿** | 低 |
| | **T-2** ✅ | `service/convert/` 纯函数包(`types`/`calc`/`fee`/`nav`/`lot_bootstrap`/`errors`)—— **93 用例全绿** | 无 | 低 |
| | **T-2b** ✅ | `calc_convert_demo.py` 实算 + 回填 PRD §5.3 与验收断言 —— **15/15 一致** | T-2 | 低 |
| **第 2 批 · 仓储与锁** | **T-3** ✅ | `core_ro` 五个新方法 + `share_lot_repository` | T-1 | 低 |
| | **T-4** ✅ | `convert_repository`(占位/回写/查询/清理) | T-1 | 低 |
| | **T-5** ✅ | `locks.try_lock` + 单测 | 无 | 低 |
| **第 3 批 · 事务与编排** | **T-6** ✅ | `convert_core_repository.apply_convert`(阶段一单事务)—— **真库 24/24** | T-1/T-3 | **高(方言 + 并发)** |
| | **T-7** ✅ | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5)—— **17 用例 + 真库 35/35** | T-2~T-6 | **高(关键路径)** |
| **第 4 批 · 引擎与网关** | **T-8** ✅ | `_amount_view` + `engine.process_convert_event` + `alert_service.events` —— **2026-09-10 完成(15 用例 + 真库 31/31)** | 无(可与 T-2 并行) | 中 |
| | T-9 | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 | T-7 | 中 |
| | T-11 | `core_tools` 汇总去重 + 持仓 `qty <= 0` 过滤 + `sum_trades_on_date` 去重 | T-8 | 中 |
| **第 5 批 · 高风险专项** | **T-10** | 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ `rebuild_lots.py` | T-3(排在 T-7 后) | **最高(打穿 510)** |
| **第 6 批 · 补偿** | T-12 | `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 | 低 |
| **第 7 批 · 收口** | T-13 | 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 | 全部 | 中 |
**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`
**并行组 A**:T-3 / T-4 / T-5(T-1 完成后同时开工)
**并行组 B**:T-8 全程可与 T-2 之后任意任务并行
**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`(**T-7 已通,T-9 已解锁**)
**并行组 A**:T-3 / T-4 / T-5(✅ 全部完成)
**并行组 B**:T-8 全程可与 T-2 之后任意任务并行(✅ 已完成)
**硬门禁**:`T-0` 与 `T-0b` **双双绿**才允许启动 T-1 及之后(T-0 用例 = `test_db.py::test_core_holding_columns`)
**测试基线**:**516**(批 0 后;原 510)→ 预计 **591 ~ 616**(CI 内约 **603**;含不进 CI 的压测用例约 **613**,见 §11)
**测试基线**:**672**(2026-09-10 T-8 后;批 0~3 路线 510 → 516 → 609 → 634 → 639 → 656 → **672**)→ 剩余任务(T-9~T-13)预计再加 **25~55** → **700~730**(估算)
---
@@ -1014,15 +1014,63 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
- `process_convert_event`:取当日全量流水(已含两条)→ `run_rules` → `record_trade_alerts(primary=out_trade, hits, events=[event_of(out), event_of(in)])` → **一张单、`payload.events` 两条**
- **`on_error_hook`(D19)**:一期传 `None`;**hook 调用必须包 `try/except`**,hook 自身失败**不得**反噬主流程(与「阶段 1.5 不阻断交易」同原则)——**必须有单测**
**DoD**
- [ ] `_amount_view` 对无 gid 交易恒等(`assert _amount_view(x) == x` 型用例)
- [ ] 一组 convert 两条流水 → RISK-002 只计一次;RISK-001/RISK-003 仍看到两条(验收 5/6)
- [ ] `process_convert_event` 只出**一条**预警单,`payload.events` 长度 2(验收 7)
- [ ] hook 抛异常时主流程正常返回(断言不抛)
- [ ] `pytest -q` 全绿(引擎既有 13 处 `run_rules` 调用零改动)
**DoD(全部达成,见下方执行记录)**
- [x] `_amount_view` 对无 gid 交易恒等(`assert _amount_view(x) == x` 型用例)
- [x] 一组 convert 两条流水 → RISK-002 只计一次;RISK-001/RISK-003 仍看到两条(验收 5/6)
- [x] `process_convert_event` 只出**一条**预警单,`payload.events` 长度 2(验收 7)
- [x] hook 抛异常时主流程正常返回(断言不抛)
- [x] `pytest -q` 全绿(引擎既有 13 处 `run_rules` 调用零改动)
**依赖**:无(可与 T-2 之后任意阶段并行)
**执行记录(2026-09-10)**
| 项 | 内容 |
| --- | --- |
| 改动文件 | `app/service/risk/rules.py`(+`_amount_view` + `run_rules` 双视图分流)· `app/service/risk/alert_service.py`(+公开 `build_trade_event` + `record_trade_alerts(events=...)`)· `app/service/risk/engine.py`(抽 `_run` + `process_convert_event` + `_notify_error_hook`) |
| 新增文件 | `tests/test_convert_engine.py`(**15 用例**)· `scripts/dev/verify_convert_engine.py`(**真 MySQL 验证 31 项**) |
| 测试改动 | `tests/test_convert_service.py` +1 条「接线回归」用例(大额转换真出单,防 `_run_engine` 退回静默跳过) |
| 结果 | pytest **672 passed / 3 skipped**(基线 656 **+16**,零回归);真库验证 **31/31 一致**(退出码 0) |
**关键实现点(三处实施级收敛,均已在代码注释留痕)**
1. **`events` 参数的语义收敛为「已构造的事件体列表」,但构造逻辑只留一份**。
架构 §6.2 写 `events=[event_of(out), event_of(in)]`,若 engine 自建 `event_of`,就会与
`record_trade_alerts` 内的 `event = {...}` 形成**两份副本**(违反自检第 13 问)。
收敛为:`alert_service.build_trade_event(trade, hits)` **公开导出**,engine 调它构造两条,
`record_trade_alerts` 内部单流水分支也调它 —— 结构定义**唯一副本**,且与架构措辞一致。
2. **聚合追加分支只追加首条(主事件)**。当日已有同客户 pending 单时,`append_alert_event`
只追加转出端事件 —— 与架构 §5.4「一次转换两条流水,**只认转出端**」同口径
(与 T-12 补偿脚本的幂等锚点同一理由)。**新建单**才落两条(验收 7)。
3. **`related_trades` 缺省 `None` 使 `process_trade_event` 行为逐字节等价**:`_run` 抽出后
`process_trade_event` 变薄封装(签名零改动),既有 13 处 `run_rules` 调用与 510 断言零影响。
**真库验证的额外产出(sqlite 绿证明不了的部分)**
| 组 | 验证内容 | 结果 |
| --- | --- | --- |
| A | **阶段 1.5 从「跳过」变「生效」**:真库实跑出一张单、`payload.events` 两条(顺序 [redeem, subscribe])、`engine_error=False` | ✅ |
| B | **RISK-002 不翻倍**:阈值夹逼(单条 512000 < 800000 < 两条之和 1019975.33)→ 不含 RISK-002 | ✅ |
| C | 去重不删行:`core_trade` 2 条同 `convert_group_id`;两端 DECIMAL(18,2) 精度零漂移 | ✅ |
| D | 幂等重试(同 `client_request_id`)→ 同 group_id、流水仍 2 条、**单仍 1 张** | ✅ |
| E | 无命中场景(换日隔离)→ 不建单、仅落 1 条 pass 审计 | ✅ |
| F | 清理后 6 张表零残留(core_trade / core_customer / risk_alert / customer_profile_l3 / audit_log / risk_convert_detail) | ✅ |
**真库验证两处脚本侧坑(已修正,与 T-7 同款)**
1. **`convert_fund` 默认 `id_factory` 生成 `CNV-<日期>-<uuid>`**,与清理口径 `LIKE 'CNV-T8M%'`
不匹配 → 重跑时遗留行撞 `uk_group`。已注入 `id_factory=_t8m_id`(`CNV-T8M-<uuid8>`),
并把清理条件补上 `client_request_id LIKE 'T8M-%'` 兜底。
2. **`core_trade.trade_type` 在 MySQL 是 ENUM,`ORDER BY` 按定义序而非字母序**
(实测 `[subscribe, redeem]`)—— 断言改用 `{trade_type: row}` 字典定位,不依赖排序。
**发现但不在本任务范围(记下来,未顺手改)**:口径先钉死 —— **RISK-001 单笔 / RISK-002 当日累计,
是两条独立规则**(`rules.py:128-133` 逐笔比对 vs `:137-145` 求和比对),T-8 只把后者切到金额视图。
真库 E 组现象(后续 1000 元小额交易也带出 RISK-001)的根因是**引擎入参范围**:
`process_trade_event` 拉**当日全量**流水重跑规则,故**触发这笔**与**命中那笔**可能不是同一笔。
既有聚合逻辑(同客户同日一张 pending 单)兜住了重复出单,属**既有设计**,T-8 不改变、
也不修(见 §B.9 纪律「不做清单外改动」)。
---
### 7.2 T-9 · API 模型与网关分派