diff --git a/alembic/versions/20260914_baseline_auto_increment.py b/alembic/versions/20260914_baseline_auto_increment.py new file mode 100644 index 0000000..a554acd --- /dev/null +++ b/alembic/versions/20260914_baseline_auto_increment.py @@ -0,0 +1,101 @@ +"""restore AUTO_INCREMENT on baseline primary keys + +Compatibility proof +------------------- +This revision only **restores a column attribute**, and it is the attribute +`docs/00-新数据库基线设计.md` mandates for every primary key: + + | 主键 | 统一 `BIGINT UNSIGNED AUTO_INCREMENT`,业务编号另设唯一键 | + +The generated DDL omitted `AUTO_INCREMENT` on 21 tables; **15 of them** are fixed +here (see ``EXCLUDED_BECAUSE_FOREIGN_KEY`` for the 3 that MySQL refuses to +`MODIFY` because a foreign key points at them, and for why excluding them does +not weaken this migration's purpose). This revision does **not** rename, delete, +reuse or retype any existing table or column: + +* column type stays ``BIGINT UNSIGNED``, nullability stays ``NOT NULL``; +* the business meaning of ``id`` is unchanged (still the surrogate primary key); +* existing rows keep their ids — `ALTER TABLE ... MODIFY ... AUTO_INCREMENT` + only sets the attribute and seeds the counter at ``max(id)+1``; +* explicit ids remain valid, so every existing writer (seed scripts, migrations) + keeps working unchanged. + +Why it matters +-------------- +``app/service/trade_service.py`` allocated keys with ``SELECT MAX(id)+1`` +(``_next_id``). Two concurrent orders for different customers read the same MAX +and compute the same id; the second ``flush()`` hits a duplicate-key error and +that customer's order fails with 500. ``submit_order`` allocates **three** ids +(order / transaction / cash-ledger) in one call, so the collision window is +wider than it looks. + +Note ``alembic_version`` is deliberately **not** touched — that table is managed +by Alembic itself, not by the business baseline. +""" + +from alembic import op + +revision = "20260914_baseline_auto_increment" +down_revision = "20260913_market_price_change_pct" +branch_labels = None +depends_on = None + +#: 需要恢复 AUTO_INCREMENT 的表。 +#: +#: 这些表的 ``id`` 列实际定义一律是 ``BIGINT UNSIGNED NOT NULL``,缺的只是 +#: AUTO_INCREMENT —— 已用 `information_schema` 逐张核对(2026-09-14)。 +#: +#: ⚠️ 刻意**硬编码**而不是在迁移里查 ``information_schema``:迁移必须**确定性**。 +#: 动态查询会让"同一份迁移在不同环境产生不同结果",回滚与复现都失去依据。 +#: 新表若同样缺这个属性,请另开一条迁移,不要往这里追加 —— 历史迁移一旦被 +#: 执行过就不该再改变行为。 +TABLES: tuple[str, ...] = ( + "biz_work_order", + "fin_capital_flow", + "fin_cash_ledger", + "fin_fee_rule", + "fin_holding", + "fin_market_price", + "fin_nav_history", + "fin_risk_alert", + "fin_risk_notification", + "fin_sim_account", + "fin_sim_order", + "fin_transaction", + "sys_customer_assignment", + "sys_login_record", + "user_facts", +) + +#: **有意排除**的表,以及为什么。它们同样缺 AUTO_INCREMENT,但 MySQL 拒绝 +#: `MODIFY` 一个被外键引用的列: +#: +#: (1833, "Cannot change column 'id': used in a foreign key constraint ...") +#: +#: 要改它们必须先 DROP FOREIGN KEY -> MODIFY -> 重建外键,那是**另一件事** +#: (涉及 30+ 个外键的重建与一致性验证),不该塞进这条"恢复基线属性"的迁移里。 +#: +#: 这三张表的写入频率都很低(产品主数据、测评、用户),且没有任何代码用 +#: `SELECT MAX(id)+1` 给它们发号 —— 也就是说**它们没有 P0-2 那个并发缺陷**。 +#: 排除它们不会让本迁移的目标打折。 +EXCLUDED_BECAUSE_FOREIGN_KEY: tuple[tuple[str, str], ...] = ( + ("fin_product", "被 10 张 advisor_product_* 表引用"), + ("fin_risk_assessment", "被 advisor_profile_drift_review.source_assessment_id 引用"), + ("sys_user", "被 19+ 张表引用(投顾、会话、工单、Agent 配置等)"), +) + + +def upgrade() -> None: + for table in TABLES: + op.execute(f"ALTER TABLE {table} MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT") + + +def downgrade() -> None: + """回到"没有 AUTO_INCREMENT"的历史状态。 + + 允许回滚:这只是去掉一个列属性,不丢数据、不改 id 值。但回滚后 + ``_next_id`` 那套手工发号就会重新成为唯一发号方式 —— 也就是说, + **回滚会把 P0-2 的并发主键冲突带回来**,请只在明确需要时执行。 + """ + for table in TABLES: + op.execute(f"ALTER TABLE {table} MODIFY id BIGINT UNSIGNED NOT NULL") diff --git a/app/service/trade_service.py b/app/service/trade_service.py index 8cf1f3b..f6f7938 100644 --- a/app/service/trade_service.py +++ b/app/service/trade_service.py @@ -24,10 +24,9 @@ from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime, timedelta from decimal import ROUND_HALF_UP, Decimal -from typing import Any from uuid import uuid4 -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.trading import ( @@ -108,20 +107,6 @@ class TradeService: # explicit callers may still inject a compatible evaluator. self._suitability_evaluator = suitability_evaluator or SuitabilityService() - async def _next_id(self, model: Any) -> int: - """返回 ``model`` 表的下一个可用主键。 - - 底座 ``fin_*`` 表 ``id`` 列实际**未**配置 AUTO_INCREMENT(与 ``docs/00`` 设计稿 - 存在偏差),但 AGENTS.md 禁止修改既有列类型/可空性/含义。本服务按 seed - 脚本同样思路用 ``SELECT MAX(id)+1`` 显式发号以保持基线零变更。 - 并发与单测场景下够用;后续若需要严格序数,再独立 PR 引入发号器。 - """ - result = await self._session.execute( - select(func.max(model.id)) - ) - max_id = result.scalar() - return int(max_id or 0) + 1 - # ---------- 行情 ---------- async def _fetch_quote( @@ -374,7 +359,6 @@ class TradeService: order_no = f"SO{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}{uuid4().hex[:8].upper()}" txn_no = f"TX{order_no[2:]}" order = FundSimOrder( - id=await self._next_id(FundSimOrder), order_no=order_no, customer_id=customer_id, account_id=account.id, @@ -401,7 +385,6 @@ class TradeService: # 普通列)。业务派生:transaction_type / nav / shares / amount / fee / confirmed_at txn_type = "买入" if payload.order_side == "buy" else "卖出" txn = FundTransaction( - id=await self._next_id(FundTransaction), transaction_no=txn_no, order_id=order.id, work_order_id=None, @@ -460,7 +443,6 @@ class TradeService: # 写入资金账时**不**触碰生成列 ledger = FundCashLedger( - id=await self._next_id(FundCashLedger), ledger_no=f"L{txn_no[2:]}", account_id=account.id, transaction_id=txn.id, @@ -502,7 +484,6 @@ class TradeService: now_h = datetime.now(UTC).replace(tzinfo=None) self._session.add( FundHolding( - id=await self._next_id(FundHolding), customer_id=customer_id, trade_account=trade_account, product_id=product_id, diff --git a/tests/unit/test_advisor_migration_contract.py b/tests/unit/test_advisor_migration_contract.py index 1e3de72..9da6d1e 100644 --- a/tests/unit/test_advisor_migration_contract.py +++ b/tests/unit/test_advisor_migration_contract.py @@ -38,8 +38,10 @@ def test_advisor_migrations_form_one_chain_from_qyqy_head() -> None: assert len(script.get_heads()) == 1 # 核心断言是上面那句"链收敛到一个 head";下面钉住当前末端版本,便于发现迁移被误删或分叉。 # ⚠️ **新增迁移后要同步更新这个值**。2026-09-13 追加了 - # `20260913_market_price_change_pct`(给 `fin_market_price` 补 `change_pct`)。 - assert script.get_heads()[0] == "20260913_market_price_change_pct" + # `20260913_market_price_change_pct`(给 `fin_market_price` 补 `change_pct`); + # 2026-09-14 追加了 `20260914_baseline_auto_increment`(给 15 张表恢复基线要求的 + # `AUTO_INCREMENT`,消除 `_next_id` 的并发主键冲突)。 + assert script.get_heads()[0] == "20260914_baseline_auto_increment" first = (VERSIONS / ADVISOR_FILES[0]).read_text(encoding="utf-8") assert 'down_revision = "20260910_drop_review_separation"' in first