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:
liuwenxinandClaude Code committed 2026-10-22 13:31:01 +08:00
1 parent 694d21d1fd
commit 9964281c14
12 files changed
+398 -42

No files matched your search

+11 -1
View File
@@ -169,7 +169,14 @@ uv run pytest tests/integration -q # 仅集成
| `tests/integration/test_secondary_allocation.py`(扩) | **同一会话内旧方案行随置历史同步刷新**(Core UPDATE 的会话同步策略退化为不刷新时,行对象会停留在 is_active=1 的过期内存值) | +1 |
| `tests/integration/test_approval_flow.py`(扩) | **送审内容未变不重提**:方案与金额都没动 → 方案不新增版本、审批单不重提(旧单仍待财务复核);**只改金额** → 方案版本不变但审批单 version_no+1 且新快照金额为改后值;**改方案比例** → 方案版本+1、审批单 version_no+1 且旧单撤回;**无在途单时保存仍提新单**(保守规则);`pending_matches_snapshot` 四态(一致/不一致/无在途单/快照为 None) | +5 |
运行:`uv run pytest tests/integration/test_approval_flow.py tests/integration/test_secondary_allocation.py tests/integration/test_schema_sync.py -q` → **37 passed**(2026-10-20);`tests/integration/test_settlements.py` → **16 passed**;`tests/integration/test_calc.py` → **20 passed**;`tests/unit` → **525 passed**(含新增 `test_approval_helpers.py` 10 例);**全量 `uv run pytest -q` → 891 passed(10:23)**。ruff、前端 `pnpm type-check` 与 `pnpm build` 均绿。
### 审批单版本号并发防线(2026-10-21 完成:V12 唯一键 + 锁读取号)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_approval_version.py`(新) | **取号读最新已提交值**(两会话法:s1 锚定快照 + s2 提交 version_no=2 → s1 必须取到 3,守住「快照读 vs 当前读」根因);**取号查询带 FOR UPDATE 且不含聚合**(事件钩子抓真实 SQL——MySQL 官方不允许聚合函数携带锁子句);**唯一键生效**(重复 (order_type, ref_id, version_no) 直插撞 1062);**编辑路径冲突收敛为 400** 且业务数据不写坏(monkeypatch `_next_version` 恒返回 1);**正常路径版本号仍递增**(1→2→3) | +5 |
| `tests/unit/test_models.py`(扩) | 审批单唯一键覆盖 (order_type, ref_id, version_no) 三列且约束名与迁移 DDL 一致 | +1 |
运行(2026-10-22,容器恢复后):`tests/integration/test_approval_version.py` + `test_dashboard.py` → **16 passed**;全量 `uv run pytest -q` → **897 passed(12:02)**;`uv run ruff check .` 绿、前端 `pnpm type-check` 绿。首次全量跑出两处**测试代码**适配(非产品缺陷):① 工作台 `_insert_approval` 用同一 `(order_type=2, ref_id=0)` 连插两张单,撞 V12 新唯一键 → 每张单改给不同 `ref_id`;② 新用例跨模块引用 `switched_on` 夹具(pytest 夹具只在定义模块与 conftest 可见,报 fixture not found)→ 用 `_set_switch` 本地重建。
## 三、运行记录
@@ -217,6 +224,9 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-20 | 每次保存带方案的业务都刷出版本与审批留痕 | 只改备注、方案一字未动,也会「置历史+新增版本+撤回旧单+提新单」,方案历史与审批留痕被噪音淹没,两个 `version_no` 语义混同 | 判据统一为**送审内容**:方案未变不新增版本行;在途单送审内容一致则不重提(`pending_matches_snapshot`,只比 `data`) |
| 2026-10-20 | 三处列表查询整列拉快照 | 审批单列表、工作台待办、审批超时预警都用 `select(ApprovalOrder)` 取全列,快照 JSON 可达上百 KB,翻页与工作台负担随快照体积线性放大 | 三处加 `defer(ApprovalOrder.snapshot_json)`(沿用操作日志列表排除 `request_data` 的既有模式) |
| 2026-10-20 | 月结提交未锁行 | 并发/双击提交同一月结时两次 `_next_version` 可能算出同一 `version_no`,送审次序失真 | `settlement_repository.get_by_id` 加 `for_update` 参数,提交路径锁行 |
| 2026-10-21 | 审批单 `version_no` 无唯一约束 | 应用层取号即使正确,并发下 DB 层无兜底;且唯一键是「一个对象至多一张在途单」红线之外版本语义的最后防线 | V12 迁移加 `uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`;加键前先重排存量重复分组(V11 把存量一律置 1 而 V11 前重提是新建行,直接加键必报 1062) |
| 2026-10-21 | `_next_version` 是快照读 | 请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已锚定 REPEATABLE READ 快照——父行锁只串行化写入时刻、**不刷新快照**,并发提单的 MAX 停在请求开始时刻 → 版本号重复 | 取号改锁读(`select(version_no)...with_for_update()`,当前读),最大值 Python 侧算;**不能用聚合 + FOR UPDATE**——MySQL 官方不允许聚合函数携带锁子句 |
| 2026-10-21 | `act()` 驳回清空方案的注释与行为相反 | 注释写「清空该业务的二次分配行…如需保留历史版本,在 ④ 设计时演进」,而 V11 已实现 append-only(只置 `is_active=0` 行不删)——误导后来者按旧语义改代码 | 注释改为「置历史不删行,历史方案可回溯」 |
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
@@ -0,0 +1,183 @@
"""审批单版本号并发防线(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]
+8 -2
View File
@@ -250,7 +250,13 @@ def leader(make_user, auth_headers) -> dict:
def _insert_approval(status: int) -> None:
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。"""
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。
``ref_id`` 仅作对象分组锚点、本用例不校验:**每张单必须给不同的
ref_id**——V12 的 ``uk_approval_orders_order_type_ref_version`` 要求
(order_type, ref_id, version_no) 唯一,同一假对象连插两张 version_no=1
会撞 1062。
"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
@@ -260,7 +266,7 @@ def _insert_approval(status: int) -> None:
ApprovalOrder(
order_no=f"AP{uuid.uuid4().hex[:12]}",
order_type=2,
ref_id=0,
ref_id=status,
refer_no="t_工作台",
status=status,
submitter_name="t_admin",
+14
View File
@@ -96,6 +96,20 @@ class TestNamingConvention:
for fk in table.foreign_keys:
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
def test_审批单版本号唯一键覆盖三列(self) -> None:
"""并发提单算重版本号时由 DB 兜底(应用层取号走锁读,见
approval_service._next_version);约束名必须与迁移 DDL 严格一致,
否则 alembic autogenerate 会误判成「约束缺失/多余」。"""
table = Base.metadata.tables["approval_orders"]
uks = [c for c in table.constraints if c.__class__.__name__ == "UniqueConstraint"]
hit = [
c
for c in uks
if {col.name for col in c.columns} == {"order_type", "ref_id", "version_no"}
]
assert hit, "审批单缺 (order_type, ref_id, version_no) 唯一键"
assert hit[0].name == "uk_approval_orders_order_type_ref_version"
class TestComments:
"""规范红线要求中文 COMMENT。"""