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 |
|