Files
group_fqcd_jr/app
lzf_0626 a337cca31a 修复 T002 下单的两个 P0:无幂等保护、读账户/持仓无行锁
## P0-1 下单没有幂等保护(实测复现过重复扣款)

`app/api/controllers/trading.py` 的 T002 此前**没有任何幂等保护** —— 全仓 7 个
controller 共 29 处声明了 `Idempotency-Key`,**唯独 trading.py 一处都没有**,
而 `docs/05` §5.2 要求写端点必须带。

**修复前实测**(同一 key 连发两次):

    两个不同 order_no:SO...FD48A488 / SO...2FB182C0
    委托 +2、可用现金 -910.50(2×455.25)、510300 持仓 3500 -> 3700
    ⇒ 重复扣款 + 重复建仓

**改法**:接 `ApiTransactionService.execute_in` —— 业务写入与幂等回执**同一个事务**
(`docs/05` §5.2),同键同 body 的第二次请求直接回放上次响应。

- `trade_service.submit_order` 新增 `commit: bool = True`:包装层调用时传
  `commit=False`,由 `execute_in` 统一提交。**不做这一步就会"内层提交外层事务"**,
  幂等回执与业务写入分处两个事务,回执写失败时业务已落库,重放失去意义。
- 响应经 `model_dump(mode="json")` 统一 JSON 化后再 `model_validate` 还原 ——
  保证"首次"与"重放"两条路径返回**同一形状**,且对外结构不变
  (金额仍是字符串化 Decimal)。

**修复后实测**(同一 key 连发两次):

    两次 order_no 完全相同:SO202609141216512C7166D4
    两次响应逐字段相同(真正的回放)
    可用现金 -455.25(单次)、510300 持仓 +100(单次)
    T003 核对:43 条委托里只多出 1 条

## P0-3 下单读账户/持仓无行锁(并发可扣穿余额)

`trade_service.py` 全文 **零 `with_for_update`**,而项目其它 15 个 service 共 38 处
用了它 —— 规范早已建立,这里是遗漏。

**改法**:`_load_account` / `_load_holding` 增加 `for_update` 开关(默认关,
只读路径不加锁、不牺牲并发),`submit_order` 以 `for_update=True` 调用。
**加锁顺序固定「账户 → 持仓」**:并发事务按同一顺序取锁才不会成环,
这一点写在两处 docstring 里,改顺序前必须先想清楚。

**实测**(真并发 2 笔,各 45520 元,合计 91040 > 余额 49103):

    第 1 笔:201 成交 SO...59568811
    第 2 笔:422 INSUFFICIENT_FUNDS「可用余额 3578.91 不足」
    成交 1/2,最终余额 3578.91 >= 0

**关键证据**:被拒那笔读到的是 **3578.91(已扣减后)**而不是初始的 49103.46 ——
证明两个事务被行锁串行化了。无锁时两笔都会读到 49103.46 而双双通过,
余额会变成 -41936。

## 实测汇总

- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
  (那 2 个是既有的:投顾页面被替换、docs/05 §19 分组行,均与本提交无关)
- `ruff check` -> All checks passed
- 两次实测的完整证据见上;两份验证脚本在 %TEMP%(未入库)

## 顺带修正一个我自己脚本的 bug

验证脚本里用 `GET /users/me/orders?limit=200` 读委托数,而 T003 的 `limit` 上限是
**100**(`Query(le=100)`)→ 422 → 读到 0 条,一度让我误判"委托没增加"。
改用 `limit=100` 后确认委托确实只 +1。
2026-09-14 20:19:55 +08:00
..
2026-09-13 15:56:54 +08:00