基金转换 T-6:apply_convert 阶段一单事务(core 库唯一写入口)
新增 app/gateway/convert_core_repository.py:两条流水(redeem/subscribe, R-b 不使用 ENUM 的 convert)+ N×条件 UPDATE 批次扣减 + 转入新批次 + 两端 core_holding + N×计费明细,全部收口在 with engine.begin() 单事务。 实现级要点(与开发计划 §6.1 的三处差异,已回写该节执行记录): - core_holding 改用「基于列当前值的增量 UPDATE」,入参不带持仓快照: MySQL 的 UPDATE 是当前读,并发两笔自然累加;用快照算绝对值会互相覆盖。 - 数值参数参与算术时写成 (:x + 0.0):实测 sqlite 在 UPDATE 算术表达式中 不把 TEXT 绑定参数转数值(传 '120' 时 c 不变),加 +0.0 后两库一致。 - pnl_pct 拆成独立 UPDATE 重算,避开 MySQL「SET 从左到右」的顺序坑。 验证:pytest 639 passed / 3 skipped(基线 634 +5,零回归); 新增 scripts/dev/verify_convert_apply.py 真 MySQL 验证 24/24 一致, 含真并发两笔首次转入同一产品的终态断言(MySQL RR 的 gap lock 实测留痕)。
This commit is contained in:
@@ -870,6 +870,52 @@ def try_lock(key: str, ttl_seconds: int = LOCK_TTL_SECONDS) -> _Token | None:
|
||||
|
||||
**依赖**:T-1 / T-3
|
||||
|
||||
**执行记录(2026-09-10)**
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 新增文件 | `app/gateway/convert_core_repository.py`(阶段一唯一写入口)· `tests/test_convert_core.py`(5 用例)· `scripts/dev/verify_convert_apply.py`(**真 MySQL 验证,24 项断言**) |
|
||||
| 落库顺序 | 2×`INSERT core_trade`(`redeem`/`subscribe`,同 `convert_group_id`)→ N×条件 `UPDATE core_share_lot` → 1×`INSERT core_share_lot`(转入新批)→ 两端 `core_holding`(R-a 三步)→ N×`INSERT core_convert_lot_detail` |
|
||||
| 结果 | pytest **639 passed / 3 skipped**(基线 634 **+5**,零回归);真库验证 **24/24 一致**(退出码 0) |
|
||||
|
||||
**关键实现点(与计划的三处差异,均已核实为"计划未覆盖"而非偏离)**
|
||||
|
||||
1. **`core_holding` 用「增量 UPDATE」,入参不带持仓快照**。
|
||||
计划 §6.1 表格写的是 `UPDATE ... SET qty/:q` 式绝对值更新,那需要调用方先读原持仓
|
||||
(T-3 的 `get_holding`)。但**快照在并发下必然陈旧**(MySQL RR 下事务内快照读看不到
|
||||
他事务提交),用它算绝对值会让并发两笔互相覆盖。故改为**基于列当前值的增量表达式**
|
||||
(`qty = qty + :dq`、`cost_amount = cost_amount * (1 - (:dq + 0.0) / qty)`):
|
||||
MySQL 的 UPDATE 是**当前读**,天然读到最新已提交值,两笔自然累加。
|
||||
2. **`( :x + 0.0)` 这个看似多余的写法(实测留痕,别删)**。
|
||||
数值参数按 `gateway_repository` 的先例用**字符串**绑定(MySQL 精确、sqlite 无损),
|
||||
但实测 **sqlite 在 UPDATE 的算术表达式里不把 TEXT 绑定参数转数值**:
|
||||
`UPDATE t SET c = c * (1 - ? / qty)` 传 `'120'` → `c` 纹丝不动(`? / qty` 得 0);
|
||||
而 `SELECT 1 - ? / 150.0` 传 `'120'` → 0.2(转成功了)。
|
||||
加 `+ 0.0` 强制数值化后两库行为一致(MySQL 本就隐式转换)。
|
||||
3. **`pnl_pct` 单独一条 UPDATE 重算**,不在主 UPDATE 里写 CASE。
|
||||
主 UPDATE 的列赋值顺序在两库语义不同(MySQL 从左到右用当前值、sqlite 恒用更新前值),
|
||||
"新值依赖新值"的口径若塞进主语句会踩顺序坑;拆出来则两库都是"读已更新值重算"。
|
||||
依赖同一条 `SET cost_amount/market_value` 在前、`qty` 扣减在后的顺序
|
||||
(让成本/市值按**原 qty** 等比例结转),已由真库 E 组与 sqlite 用例双向锁定。
|
||||
|
||||
**真 MySQL 验证的额外产出(sqlite 绿证明不了的部分)**
|
||||
|
||||
| 组 | 验证内容 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| A | MySQL 的 SET 从左到右(成本等比例结转 `150 → 30.00`)、`(:x + 0.0)` 语法、DECIMAL 精度 | ✅ |
|
||||
| B | InnoDB 真实事务回滚(冲突后流水/明细/批次零残留) | ✅ |
|
||||
| C | 转出端归零保留行、转入端累加非覆盖 | ✅ |
|
||||
| D | `uk_cust_product` 唯一索引存在(R-a 回退的前提)、`idx_convert_group` 存在(T-7 依赖) | ✅ |
|
||||
| E | **真并发两笔首次转入同一产品**:终态只有一行、`qty` = 成功笔数 × 单笔份额、失败笔零残留 | ✅ 两笔均成功,`214.70 = 2 × 107.35` |
|
||||
|
||||
**⚠️ 真并发的新认知(写进架构 §5.1 时注意)**:MySQL RR 下,`UPDATE core_holding
|
||||
WHERE (cid, pid)` 对**不存在的行会加 gap lock**(实测:另一连接的同键 INSERT 被阻塞至
|
||||
锁等待超时)。因此 R-a 第③步的 `IntegrityError` 回退在真库下**由 gap lock 天然兜住**
|
||||
(并发两笔被串行,第二笔要么等待后撞重复键走回退、要么直接命中 UPDATE 累加);
|
||||
sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
|
||||
(`_insert_holding_row`,位于 UPDATE 之后、INSERT 之前)精确覆盖,
|
||||
并以 `hit["fallback"] is True` 断言"确实走到回退分支",防用例假绿。
|
||||
|
||||
---
|
||||
|
||||
### 6.2 T-7 · `convert_service` 编排(关键路径 · 高风险)
|
||||
|
||||
Reference in New Issue
Block a user