基金转换 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
@@ -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 模型与网关分派