- 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>
184 lines
8.1 KiB
Python
184 lines
8.1 KiB
Python
"""审批单版本号并发防线(V12 唯一键 + 锁读取号)。
|
||
|
||
守的是:
|
||
- **取号必须读最新已提交值**:请求级鉴权与业务共用同一 DbSession,鉴权
|
||
的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻;父行锁只
|
||
串行化写入时刻、不刷新快照——取号若是快照读,并发提单会算出同一个
|
||
version_no(两会话法做行为级回归锁,不依赖真多线程)
|
||
- **取号查询带 FOR UPDATE 且不用聚合**:MySQL 官方不允许聚合函数携带
|
||
锁子句(Locking clauses are not permitted in queries with aggregate
|
||
functions or GROUP BY),聚合上的 FOR UPDATE 语义无保证
|
||
- **DB 层唯一键生效**:即使应用层取号缺陷被绕过,重复
|
||
(order_type, ref_id, version_no) 也必须在库上被拒(1062)
|
||
- **冲突收敛为 400**:编辑路径撞唯一键 → 审计上下文回滚 → 业务提示
|
||
可重试,业务数据不得写坏
|
||
- **正常路径版本号仍递增**:锁读改写不破坏既有 1→2→3 行为
|
||
"""
|
||
|
||
import uuid
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import event, text
|
||
|
||
from app.core.db import get_engine, get_session_factory
|
||
from app.core.db_errors import is_duplicate_entry
|
||
from app.models import ApprovalOrder
|
||
from tests.integration.factories import business_payload
|
||
from tests.integration.test_approval_flow import (
|
||
_BUSINESSES,
|
||
_create_with_plan,
|
||
_latest_order,
|
||
_person,
|
||
_set_switch,
|
||
)
|
||
|
||
pytestmark = pytest.mark.integration
|
||
|
||
|
||
@pytest.fixture
|
||
def switched_on(client: TestClient, admin: dict) -> None:
|
||
"""把二次分配审批开关置 1(用例结束由 conftest 恢复种子基线)。
|
||
|
||
pytest 夹具只在定义它的模块与 conftest 中可见,跨模块直接引用会报
|
||
``fixture 'switched_on' not found``;这里用 ``_set_switch`` 本地重建。
|
||
"""
|
||
_set_switch(client, admin["headers"], "1")
|
||
|
||
|
||
class TestNextVersionLockRead:
|
||
"""取号查询的锁读口径(真实 SQL 形态 + 行为级快照回归)。"""
|
||
|
||
def test_取号查询带FOR_UPDATE_不含聚合(self, client: TestClient, admin: dict) -> None:
|
||
"""抓 _next_version 真实发出的 SQL(比本地重造语句再编译更贴真):
|
||
必须含 FOR UPDATE、不含 MAX( —— 聚合上的锁子句 MySQL 不保证语义。"""
|
||
|
||
captured: list[str] = []
|
||
|
||
def _spy(_conn, _cursor, statement, *_rest):
|
||
if "approval_orders" in statement and "version_no" in statement:
|
||
captured.append(statement)
|
||
|
||
engine = get_engine()
|
||
event.listen(engine, "before_cursor_execute", _spy)
|
||
try:
|
||
p = _person(client, admin["headers"])
|
||
_create_with_plan(client, admin["headers"], p["id"]) # 触发一次真实提单
|
||
finally:
|
||
event.remove(engine, "before_cursor_execute", _spy)
|
||
|
||
assert captured, "未捕获到取号查询"
|
||
assert any("FOR UPDATE" in sql.upper() for sql in captured), captured
|
||
assert not any("MAX(" in sql.upper() for sql in captured), captured
|
||
|
||
def test_版本号读最新已提交值_不受事务旧快照影响(self) -> None:
|
||
"""两会话法(无需线程):s1 先锚定快照 → s2 提交新版本 →
|
||
s1 取号必须读到 3 而非旧快照里的 2——守住「快照读 vs 当前读」根因。"""
|
||
from app.services import approval_service
|
||
|
||
factory = get_session_factory()
|
||
with factory() as s1:
|
||
# 锚定快照(模拟请求级鉴权读先于业务写发生)
|
||
s1.execute(text("SELECT order_no FROM approval_orders LIMIT 1")).all()
|
||
|
||
with factory() as s2:
|
||
order = ApprovalOrder(
|
||
order_no=f"AP-{uuid.uuid4().hex[:12]}",
|
||
order_type=1,
|
||
ref_id=987654321,
|
||
refer_no="YW-SNAPSHOT-TEST",
|
||
version_no=2,
|
||
status=5,
|
||
submitter_name="t_snapshot_probe",
|
||
)
|
||
s2.add(order)
|
||
s2.commit()
|
||
|
||
# s1 的普通读仍停在旧快照(该分组在快照里不存在)
|
||
stale = s1.execute(
|
||
text(
|
||
"SELECT COALESCE(MAX(version_no),0) FROM approval_orders "
|
||
"WHERE order_type=1 AND ref_id=987654321"
|
||
)
|
||
).scalar_one()
|
||
assert stale == 0, "前置失效:快照读应停在锚定时刻,先修隔离级别再跑本用例"
|
||
|
||
assert approval_service._next_version(s1, 1, 987654321) == 3, (
|
||
"取号必须读到最新已提交值(2+1=3),读到 2 说明退化为快照读"
|
||
)
|
||
s1.rollback()
|
||
|
||
def test_唯一键生效_重复版本号被拒(self) -> None:
|
||
"""重复 (order_type, ref_id, version_no) 直插必须撞 1062——
|
||
应用层取号即使被绕过,DB 也是最后一道防线。"""
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
factory = get_session_factory()
|
||
with factory() as s:
|
||
common = {
|
||
"order_type": 1,
|
||
"ref_id": 987654322,
|
||
"refer_no": "YW-UK-TEST",
|
||
"status": 5,
|
||
"submitter_name": "t_uk_probe",
|
||
}
|
||
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
|
||
s.flush()
|
||
s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common))
|
||
with pytest.raises(IntegrityError) as exc_info:
|
||
s.flush()
|
||
assert is_duplicate_entry(exc_info.value), "应为唯一键冲突(1062)而非其他完整性错误"
|
||
s.rollback()
|
||
|
||
def test_正常路径版本号仍递增(self, client: TestClient, admin: dict, switched_on) -> None:
|
||
"""常规编辑两次:送审版本号 1→2→3——锁读改写不得破坏既有行为。"""
|
||
p = _person(client, admin["headers"])
|
||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||
|
||
first = _latest_order(client, admin["headers"], data["business_no"])
|
||
assert first["version_no"] == 1
|
||
|
||
for ratio in ("60.00", "70.00"):
|
||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()[
|
||
"data"
|
||
]
|
||
payload = business_payload(
|
||
detail["customer_id"],
|
||
secondary_allocations=[{"personnel_id": p["id"], "ratio": ratio}],
|
||
)
|
||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
third = _latest_order(client, admin["headers"], data["business_no"])
|
||
assert third["version_no"] == 3
|
||
|
||
|
||
class TestConflictConvergence:
|
||
"""编辑路径撞版本号唯一键 → 收敛为可重试的 400,业务数据不写坏。"""
|
||
|
||
def test_编辑路径版本号冲突_收敛为400(
|
||
self, client: TestClient, admin: dict, switched_on, monkeypatch
|
||
) -> None:
|
||
from app.services import approval_service
|
||
|
||
p = _person(client, admin["headers"])
|
||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||
before = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||
|
||
# 模拟取号读到过期快照:恒返回 1(库中 V1 已存在)→ 撞唯一键
|
||
monkeypatch.setattr(approval_service, "_next_version", lambda *a, **k: 1)
|
||
|
||
payload = business_payload(
|
||
before["customer_id"],
|
||
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
|
||
)
|
||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||
assert resp.status_code == 400, resp.text
|
||
assert "版本号生成重复" in resp.json()["message"]
|
||
|
||
monkeypatch.undo()
|
||
# 业务数据不得写坏:整个事务回滚,方案仍是 50.00(V1 生效行)
|
||
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||
assert [a["ratio"] for a in after["secondary_allocations"]] == ["50.00"]
|
||
assert [h["version_no"] for h in after["allocation_history"]] == [1]
|