Commit Graph
7 Commits
Author SHA1 Message Date
lzf_0626 6b2e2edcda P0-2:恢复基线要求的 AUTO_INCREMENT,消除手工发号的并发主键冲突
## 问题

`trade_service._next_id` 用 `SELECT MAX(id)+1` 发主键。两个事务读到同一个 MAX、
算出同一个 id,后写的那笔 `flush()` 撞 `Duplicate entry ... for key 'PRIMARY'`
-> 该客户下单直接 **500**。`submit_order` 一次要发 **3 个 id**
(订单 / 成交 / 资金流水),冲突面是单表的三倍。

## 这是"修正偏差",不是"改基线"

`docs/00-新数据库基线设计.md` 第 41 行:

    | 主键 | 统一 `BIGINT UNSIGNED AUTO_INCREMENT`,业务编号另设唯一键 |

**基线本来就要求 AUTO_INCREMENT**,是生成的 DDL 漏了 —— `_next_id` 自己的
docstring 也写着"与 docs/00 设计稿存在偏差"。所以本迁移**不违反** AGENTS.md
规则 4(禁止改类型/可空性/业务含义):类型仍是 `BIGINT UNSIGNED`、仍是 `NOT NULL`、
`id` 的业务含义不变,只是补回一个列属性;已有行 id 不变,显式给 id 依然合法。

## 迁移 `20260914_baseline_auto_increment`

- **15 张表**恢复 AUTO_INCREMENT(硬编码表名 —— 迁移必须确定性,动态查
  `information_schema` 会让同一份迁移在不同环境产生不同结果)。
- **有意排除 3 张**(`EXCLUDED_BECAUSE_FOREIGN_KEY`):`fin_product`(被 10 张
  `advisor_product_*` 引用)、`fin_risk_assessment`、`sys_user`(被 19+ 张引用)。
  MySQL 拒绝 `MODIFY` 被外键引用的列:

      (1833, "Cannot change column 'id': used in a foreign key constraint ...")

  改它们必须先 DROP FOREIGN KEY -> MODIFY -> 重建外键,那是另一件事(涉及 30+ 个
  外键的重建与一致性验证),不该塞进这条"恢复基线属性"的迁移。且这三张表**写入
  频率很低、没有任何代码用 `SELECT MAX(id)+1` 给它们发号** —— 排除它们不影响
  本迁移的目标。
- `downgrade()` 可回滚(只是去掉属性、不丢数据),但注释里写明:**回滚会把 P0-2
  的并发冲突带回来**。

⚠️ 迁移执行中踩到过"部分生效":MySQL DDL 非事务性,第一次跑到 `fin_product`
才报错,**前 7 张已经改完**。修正列表后重跑即收敛(对已是 AUTO_INCREMENT 的列
再 `MODIFY` 是无害的)。这一点也说明**迁移必须逐表可重入**。

## 代码

`trade_service.py` 删除 `_next_id` 方法及 4 处调用(`FundSimOrder` /
`FundTransaction` / `FundCashLedger` / `FundHolding`),改由 InnoDB 分配;
顺带清掉因此不再使用的 `Any` 与 `func` import(全仓 grep 确认它们只服务于
`_next_id`)。测试对 `_next_id` 零依赖(已 grep 确认)。

`test_advisor_migration_contract.py` 里那个"钉住末端版本"的断言按它自己的注释
要求同步更新到新 head。

## 实测

- `alembic upgrade head` -> `current = 20260914_baseline_auto_increment`,
  复核状态:**15 张已生效、3 张按设计排除**
- **并发下单实测**(2 个客户 × 3 笔 = 6 笔真并发;刻意用**不同客户**,
  因为 P0-3 的行锁已经把同一客户串行化了,不同客户才会真正并发进入发号路径):

      成功 6 / 主键冲突 0 / 其它失败 0
      => P0-2 已解决

- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
  (2 个既有失败与本次无关)
- `ruff check` -> All checks passed
2026-09-14 20:27:35 +08:00
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
lzf_0626 f3af00e645 fix(trade): 补 from None 修掉 ruff B904;附 integration 抢队列的复现结论
两件事,都源自组员 e3c316a(下单授权与适当性校验)合并之后:

1) ruff B904:app/service/trade_service.py 在 except 分支里抛
   SuitabilityMismatchError 时没写 from None/from exc,门禁因此变红。
   这里被捕获的是"风险等级字段解析失败",对上层没有诊断价值,
   用 from None 截断因果链即可。

2) tests/integration 出现 2 个失败,**与代码无关**,是常驻 Agent Worker 抢队列:
   - 带 Worker 跑:102 passed / 2 failed
   - 停掉 Worker 跑:104 passed(已复现两次)
   失败用例是 test_complete_run_rolls_back_every_write_on_outbox_conflict 与
   test_cancel_is_idempotent_and_terminates_original_request,都是 run 队列相关 ——
   Worker 会把测试创建的 run 领走并执行,测试自己的状态机就乱了。
   这正是 docs/20 与 AGENTS.md「跑验收前先停常驻 Worker」那条的实际复现,
   顺手把结论写进 commit 备查:判断 integration 红不红之前,先确认 Worker 在不在跑。
2026-09-13 19:30:59 +08:00
张胜宇 e3c316aafb Enforce trading authorization and suitability checks 2026-09-13 19:24:59 +08:00
张胜宇 e38ece32bf 前端提交 2026-09-13 15:56:54 +08:00
lzf_0626 cb13f9cf45 fix(types): trade_service._next_id 的 model 参数标注改为 Any(mypy: type 无 id 属性) 2026-09-12 16:30:54 +08:00
张胜宇 ebc3fe4cbe feat(§T): 账户看板 + 场内模拟交易 9 端点(用户自助首版)
新增 §T 用户自助段(docs/05 §19 新号段 7 个 = A×40/C×7/K×4/M×4/O×3/R×4/T×9):

- T001 GET /api/v1/users/me/account/dashboard  — 账户/资金/持仓/盈亏汇总

- T002 POST /api/v1/users/me/orders  — 委托提交(首版 market 立即全额成交)

- T003 / T004 / T005  委托列表/详情/撤单

- T006 GET /api/v1/users/me/holdings  — 持仓列表(含市值/盈亏/当日盈亏)

- T007 / T008  成交记录列表/详情

- T009 GET /api/v1/users/me/cash-ledger  — 资金账本

要点(与 docs/00 §6.6 一致):

- 首版市价委托立即全额成交,不实现撮合队列/部分成交;T005 撤单首版对任何在场委托返回 ORDER_NOT_CANCELLABLE (409)

- 价格来源复用 base FundQuoteService;service 层不二次封装(满足 AGENTS 第 2 条)

- 首版风控 3 条硬性:产品可交易、客户适当性、持仓比例上限(fin_market_price 缺失或过期 → 拒绝买入)

- 数据库零修改:10 张 fin_* 表全部 docs/00 既定,本批 PR 改列类型与可空性均 0;底座实际偏差(id 无 AUTO_INCREMENT、所谓'生成列'是普通 NOT NULL)由 service _next_id / 业务派生值补偿

注册 API:9 端点均注册进 app.main;user=9001(cust)'s id 写账

权限码(tools/seed_test_rbac.py 同步登记 + CUSTOMER 全量):

  9047 account:read:self

  9048 trade:order:create

  9049 trade:order:read

  9050 trade:order:cancel

  9051 holding:read:self

  9052 trade:txn:read

错误码(app/core/errors.py + docs/05 §3.6 + tests/unit/core/test_errors.py DOCUMENTED 三方同步):

  404 ACCOUNT_NOT_FOUND / ORDER_NOT_FOUND

  409 ORDER_NOT_CANCELLABLE

  422 INSUFFICIENT_FUNDS / INSUFFICIENT_HOLDING / HOLDING_RATIO_EXCEEDED / SUITABILITY_MISMATCH / PRODUCT_NOT_TRADABLE

  503 FUND_QUOTE_UNAVAILABLE(可重试)

新增:app/api/controllers/trading.py / app/api/schemas/trading.py / app/service/trade_service.py / tools/seed_sim_account_demo.py / tests/unit/service/test_trade_service.py(unit×8) / tests/contract/test_trading_endpoint_contract.py(contract×11)

修改:app/main.py(挂载 controller) / app/core/errors.py(10 新异常类) / tools/seed_test_rbac.py / docs/05-接口文档.md(§19 T001-T009 + §3.6 9 新码) / tests/unit/core/test_errors.py(DOCUMENTED 同步)

门禁:pytest tests/unit tests/contract 1313 passed (+19 新增) / ruff all clean / 三道守卫全过
2026-09-12 15:50:37 +08:00