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
@@ -296,11 +296,11 @@ def act(
|
||||
operator_name=operator.username,
|
||||
)
|
||||
)
|
||||
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
|
||||
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
|
||||
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
|
||||
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
|
||||
# 委托 business_repository 完成 —— service 层互调、不触 commit。
|
||||
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
|
||||
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
|
||||
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
|
||||
# 详情 allocation_history 回溯。委托 business_repository 完成
|
||||
# —— service 层互调、不触 commit。
|
||||
if rejected and order.order_type == 1:
|
||||
from app.repositories import business_repository
|
||||
|
||||
@@ -414,18 +414,34 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
|
||||
|
||||
|
||||
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
|
||||
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。"""
|
||||
return (
|
||||
int(
|
||||
session.execute(
|
||||
select(func.coalesce(func.max(ApprovalOrder.version_no), 0)).where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
)
|
||||
).scalar_one()
|
||||
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
|
||||
|
||||
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
|
||||
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
|
||||
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
|
||||
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
|
||||
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
|
||||
已提交数据。
|
||||
|
||||
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
|
||||
「Locking clauses are not permitted in queries with aggregate functions
|
||||
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
|
||||
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
|
||||
version_no 单列,不把 snapshot_json 拉进内存。
|
||||
"""
|
||||
versions = (
|
||||
session.execute(
|
||||
select(ApprovalOrder.version_no)
|
||||
.where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
+ 1
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return (max(versions) if versions else 0) + 1
|
||||
|
||||
|
||||
def allocation_content(session: Session, business: Business) -> dict:
|
||||
|
||||
Reference in new issue
Block a user