fix(approval): 版本号唯一键兜底 + 取号改当前读防并发重复
- V12 迁移:先重排存量重复 (order_type, ref_id) 分组的 version_no (V11 把存量一律置 1,而 V11 前重提是新建行),再加 uk_approval_orders_order_type_ref_version;只重排真有重复的分组, 避免改动前端已展示过的历史版本号 - _next_version 改锁读(select version_no ... FOR UPDATE,最大值在 Python 侧算):请求级鉴权的普通 SELECT 已把 REPEATABLE READ 快照 锚定在请求开始时刻,原 MAX 快照读在并发下会算出同一版本号; 聚合 + FOR UPDATE 为 MySQL 明确禁止,故不锁聚合结果 - 编辑与月结提交路径把 1062 收敛为 400「提交冲突(版本号生成重复), 请重试」,其余完整性错误原样上抛;submit_order 内不 catch/rollback - 驳回清空方案的过时注释改为 V11 起的 append-only 语义(置历史不删行) - init-dev.sql 同步唯一键与 alembic_version 7f4f7b918bc4,导出 V12 SQL - 测试:集成 +5(锁读 SQL 形态、两会话快照回归、唯一键 1062、冲突 400、 正常递增)+ 单元 +1;工作台假审批单改用不同 ref_id 适配唯一键 - 全量回归 897 passed;ruff 与前端 type-check 绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
694d21d1fd
commit
9964281c14
12 files changed
+398
-42
No files matched your search
@@ -0,0 +1,71 @@
|
||||
"""v12 审批单版本号唯一键
|
||||
|
||||
Revision ID: 7f4f7b918bc4
|
||||
Revises: f3a8c2d61b47
|
||||
Create Date: 2026-10-21 10:15:00.000000
|
||||
|
||||
给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住
|
||||
「同一引用对象的第几次送审」在并发下算重。
|
||||
|
||||
**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL
|
||||
DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回
|
||||
只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一
|
||||
``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接
|
||||
``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。
|
||||
|
||||
重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``),
|
||||
按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示
|
||||
过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7f4f7b918bc4"
|
||||
down_revision: str | Sequence[str] | None = "f3a8c2d61b47"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_UK = "uk_approval_orders_order_type_ref_version"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""先重排存量重复的版本号,再加唯一键。"""
|
||||
# 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。
|
||||
# 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137
|
||||
# "Can't reopen table"。
|
||||
op.execute(
|
||||
"UPDATE approval_orders ao "
|
||||
"JOIN (SELECT id, ROW_NUMBER() OVER ("
|
||||
" PARTITION BY order_type, ref_id ORDER BY id) AS rn "
|
||||
" FROM approval_orders "
|
||||
" WHERE (order_type, ref_id) IN ("
|
||||
" SELECT order_type, ref_id FROM approval_orders "
|
||||
" GROUP BY order_type, ref_id "
|
||||
" HAVING COUNT(*) > COUNT(DISTINCT version_no))) t "
|
||||
"ON ao.id = t.id "
|
||||
"SET ao.version_no = t.rn"
|
||||
)
|
||||
# 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘,
|
||||
# 重跑会报 1091(键已存在)。写法沿用 V9 先例。
|
||||
op.execute(
|
||||
"SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
|
||||
"WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' "
|
||||
f"AND CONSTRAINT_NAME = '{_UK}')"
|
||||
)
|
||||
op.execute(
|
||||
"SET @ddl := IF(@uk_exists = 0, "
|
||||
f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} "
|
||||
"UNIQUE (order_type, ref_id, version_no)', 'DO 0')"
|
||||
)
|
||||
op.execute("PREPARE stmt FROM @ddl")
|
||||
op.execute("EXECUTE stmt")
|
||||
op.execute("DEALLOCATE PREPARE stmt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有
|
||||
重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。"""
|
||||
op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}")
|
||||
Reference in new issue
Block a user