2026-10-14 03:09:05 -04:00
|
|
|
|
"""单据编号唯一键冲突重试的行为锁定(audit 上下文内循环重试)。
|
|
|
|
|
|
|
|
|
|
|
|
守:1062 兜底重试若以「吞异常后 return 重入整个 create_*」实现,会产生
|
|
|
|
|
|
三重缺陷 —— 外层审计把已回滚的首次尝试记成 success=True(假成功,与
|
|
|
|
|
|
audit_service.py「上下文内不得吞掉异常后正常退出」的约定相反)、重试再
|
|
|
|
|
|
开一层审计(违反「不支持嵌套」)、持续冲突时递归至 RecursionError。
|
|
|
|
|
|
修复口径:审计上下文内 for 循环重试(上限 _NO_RETRY),成功/最终失败
|
|
|
|
|
|
各恰好一条操作日志(以 POST 前的日志条数为基线断言增量)。
|
|
|
|
|
|
|
|
|
|
|
|
制造冲突的手段:monkeypatch ``business_service._generate_no`` 首次返回
|
|
|
|
|
|
库内已存在编号 → flush 撞唯一键 → 走重试分支。审计按 username 冗余溯
|
|
|
|
|
|
源(账号删除后 user_id 被 SET NULL),断言以基线增量免受既有数据干扰。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
2026-10-22 07:43:20 -04:00
|
|
|
|
from datetime import date
|
2026-10-14 03:09:05 -04:00
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
from app.core.constants import ACTION_CREATE, MODULE_BUSINESS, MODULE_RECEIPT
|
2026-10-14 03:09:05 -04:00
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.main import app
|
|
|
|
|
|
from app.models import OperationLog
|
2026-10-22 07:43:20 -04:00
|
|
|
|
from app.services import business_service, receipt_service
|
|
|
|
|
|
from tests.integration.factories import create_business
|
2026-10-14 03:09:05 -04:00
|
|
|
|
|
|
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
|
|
|
|
|
|
_BUSINESSES = "/api/v1/business/businesses"
|
|
|
|
|
|
_CUSTOMERS = "/api/v1/base/customers"
|
2026-10-22 07:43:20 -04:00
|
|
|
|
_RECEIPTS = "/api/v1/business/receipts"
|
2026-10-14 03:09:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def quiet_client():
|
|
|
|
|
|
"""持续冲突用例依赖全局异常处理器产出 500 信封。
|
|
|
|
|
|
|
|
|
|
|
|
TestClient 默认对 ServerErrorMiddleware 的响应直接 re-raise
|
|
|
|
|
|
(raise_server_exceptions=True),会掩盖「原子处理器已正确收敛 500」
|
|
|
|
|
|
的行为本身 —— 审计断言反而无从谈起。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _customer(client: TestClient, headers: dict) -> int:
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_CUSTOMERS,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"company_name": f"t_retry公司_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
"owner_name": "张老板",
|
|
|
|
|
|
"customer_type": "personal",
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return int(resp.json()["data"]["id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _business_payload(customer_id: int) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"customer_id": customer_id,
|
|
|
|
|
|
"business_date": "2026-01-01",
|
|
|
|
|
|
"items": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
"product_category": "municipal_water",
|
|
|
|
|
|
"quantity": "1.00",
|
|
|
|
|
|
"sale_price": "10.00",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log_count(username: str, *, success: bool) -> int:
|
|
|
|
|
|
"""按操作人统计「创建业务」操作日志条数(日志按 username 冗余追溯)。"""
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
stmt = (
|
|
|
|
|
|
select(func.count())
|
|
|
|
|
|
.select_from(OperationLog)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
OperationLog.username == username,
|
|
|
|
|
|
OperationLog.module == MODULE_BUSINESS,
|
|
|
|
|
|
OperationLog.action == ACTION_CREATE,
|
|
|
|
|
|
OperationLog.summary.like("创建业务%"),
|
|
|
|
|
|
OperationLog.success == int(success),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return int(session.execute(stmt).scalar_one())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def _baseline(writer: dict) -> tuple[int, int]:
|
2026-10-14 03:09:05 -04:00
|
|
|
|
"""POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。"""
|
|
|
|
|
|
return (
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_log_count(writer["username"], success=True),
|
|
|
|
|
|
_log_count(writer["username"], success=False),
|
2026-10-14 03:09:05 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAuditRetry:
|
|
|
|
|
|
"""重试成功与持续失败两条路径的审计记录数。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_首次撞号重试成功_审计恰一条真成功(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, quiet_client: TestClient, writer: dict, monkeypatch
|
2026-10-14 03:09:05 -04:00
|
|
|
|
) -> None:
|
|
|
|
|
|
client = quiet_client
|
2026-11-01 20:42:53 -05:00
|
|
|
|
customer_id = _customer(client, writer["headers"])
|
2026-10-14 03:09:05 -04:00
|
|
|
|
first = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
2026-10-14 03:09:05 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert first.status_code == 200, first.text
|
|
|
|
|
|
existing_no = first.json()["data"]["business_no"]
|
|
|
|
|
|
|
|
|
|
|
|
real_generate = business_service._generate_no
|
|
|
|
|
|
calls = {"n": 0}
|
|
|
|
|
|
|
|
|
|
|
|
def flaky_generate(session):
|
|
|
|
|
|
calls["n"] += 1
|
|
|
|
|
|
if calls["n"] == 1:
|
|
|
|
|
|
return existing_no # 首次返回库内已存在编号 → flush 撞 1062
|
|
|
|
|
|
return real_generate(session)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(business_service, "_generate_no", flaky_generate)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
base_ok, base_fail = _baseline(writer)
|
2026-10-14 03:09:05 -04:00
|
|
|
|
|
|
|
|
|
|
second = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
2026-10-14 03:09:05 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert second.status_code == 200, second.text
|
|
|
|
|
|
assert calls["n"] == 2, "重试应恰好再取一次号"
|
|
|
|
|
|
# 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功
|
|
|
|
|
|
# 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0
|
2026-11-01 20:42:53 -05:00
|
|
|
|
assert _log_count(writer["username"], success=True) == base_ok + 1
|
|
|
|
|
|
assert _log_count(writer["username"], success=False) == base_fail
|
2026-10-14 03:09:05 -04:00
|
|
|
|
|
|
|
|
|
|
def test_持续冲突_最终失败且审计恰一条失败(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, quiet_client: TestClient, writer: dict, monkeypatch
|
2026-10-14 03:09:05 -04:00
|
|
|
|
) -> None:
|
|
|
|
|
|
client = quiet_client
|
2026-11-01 20:42:53 -05:00
|
|
|
|
customer_id = _customer(client, writer["headers"])
|
2026-10-14 03:09:05 -04:00
|
|
|
|
first = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
2026-10-14 03:09:05 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert first.status_code == 200, first.text
|
|
|
|
|
|
existing_no = first.json()["data"]["business_no"]
|
|
|
|
|
|
|
|
|
|
|
|
def always_conflict(session):
|
|
|
|
|
|
return existing_no
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(business_service, "_generate_no", always_conflict)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
base_ok, base_fail = _baseline(writer)
|
2026-10-14 03:09:05 -04:00
|
|
|
|
|
|
|
|
|
|
second = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
2026-10-14 03:09:05 -04:00
|
|
|
|
)
|
2026-10-14 22:58:44 -04:00
|
|
|
|
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
|
|
|
|
|
|
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
|
|
|
|
|
|
assert second.status_code == 400, second.text
|
|
|
|
|
|
assert "完整性冲突" in second.json()["message"]
|
2026-10-14 03:09:05 -04:00
|
|
|
|
# 修复前的症状:return _retry_create 无界递归直至 RecursionError,
|
|
|
|
|
|
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
2026-11-01 20:42:53 -05:00
|
|
|
|
assert _log_count(writer["username"], success=False) == base_fail + 1
|
|
|
|
|
|
assert _log_count(writer["username"], success=True) == base_ok
|
2026-10-22 07:43:20 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _receipt_log_count(username: str, *, success: bool) -> int:
|
|
|
|
|
|
"""按操作人统计「新增回款」操作日志条数。"""
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
stmt = (
|
|
|
|
|
|
select(func.count())
|
|
|
|
|
|
.select_from(OperationLog)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
OperationLog.username == username,
|
|
|
|
|
|
OperationLog.module == MODULE_RECEIPT,
|
|
|
|
|
|
OperationLog.action == ACTION_CREATE,
|
|
|
|
|
|
OperationLog.summary.like("新增回款%"),
|
|
|
|
|
|
OperationLog.success == int(success),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return int(session.execute(stmt).scalar_one())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _receipt_payload(business_id: int) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"business_id": business_id,
|
|
|
|
|
|
"receipt_date": str(date.today()),
|
|
|
|
|
|
"amount": "1200.00",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReceiptAuditRetry:
|
|
|
|
|
|
"""回款新增的 1062 重试路径(回款与业务同构,但走独立实现)。
|
|
|
|
|
|
|
|
|
|
|
|
回款侧的重试循环本次从 create 函数体内移入 helper 调用,重试边界、
|
|
|
|
|
|
审计条数、批次号重取都必须保持原样 —— 用例会盯住这三条。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_首次撞号重试成功_审计恰一条真成功(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, quiet_client: TestClient, writer: dict, monkeypatch
|
2026-10-22 07:43:20 -04:00
|
|
|
|
) -> None:
|
|
|
|
|
|
client = quiet_client
|
2026-11-01 20:42:53 -05:00
|
|
|
|
business = create_business(client, writer["headers"])
|
2026-10-22 07:43:20 -04:00
|
|
|
|
first = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
2026-10-22 07:43:20 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert first.status_code == 200, first.text
|
|
|
|
|
|
existing_no = first.json()["data"]["receipt_batch_no"]
|
|
|
|
|
|
|
|
|
|
|
|
real_generate = receipt_service._generate_no
|
|
|
|
|
|
calls = {"n": 0}
|
|
|
|
|
|
|
|
|
|
|
|
def flaky_generate(session):
|
|
|
|
|
|
calls["n"] += 1
|
|
|
|
|
|
if calls["n"] == 1:
|
|
|
|
|
|
return existing_no # 首次返回库内已存在批次号 → flush 撞 1062
|
|
|
|
|
|
return real_generate(session)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
base_ok = _receipt_log_count(writer["username"], success=True)
|
|
|
|
|
|
base_fail = _receipt_log_count(writer["username"], success=False)
|
2026-10-22 07:43:20 -04:00
|
|
|
|
|
|
|
|
|
|
second = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
2026-10-22 07:43:20 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert second.status_code == 200, second.text
|
|
|
|
|
|
assert calls["n"] == 2, "重试应恰好再取一次号"
|
2026-11-01 20:42:53 -05:00
|
|
|
|
assert _receipt_log_count(writer["username"], success=True) == base_ok + 1
|
|
|
|
|
|
assert _receipt_log_count(writer["username"], success=False) == base_fail
|
2026-10-22 07:43:20 -04:00
|
|
|
|
|
|
|
|
|
|
def test_持续冲突_最终失败且审计恰一条失败(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self, quiet_client: TestClient, writer: dict, monkeypatch
|
2026-10-22 07:43:20 -04:00
|
|
|
|
) -> None:
|
|
|
|
|
|
client = quiet_client
|
2026-11-01 20:42:53 -05:00
|
|
|
|
business = create_business(client, writer["headers"])
|
2026-10-22 07:43:20 -04:00
|
|
|
|
first = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
2026-10-22 07:43:20 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert first.status_code == 200, first.text
|
|
|
|
|
|
existing_no = first.json()["data"]["receipt_batch_no"]
|
|
|
|
|
|
|
|
|
|
|
|
def always_conflict(session):
|
|
|
|
|
|
return existing_no
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
base_ok = _receipt_log_count(writer["username"], success=True)
|
|
|
|
|
|
base_fail = _receipt_log_count(writer["username"], success=False)
|
2026-10-22 07:43:20 -04:00
|
|
|
|
|
|
|
|
|
|
second = client.post(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
2026-10-22 07:43:20 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert second.status_code == 400, second.text
|
2026-11-01 20:42:53 -05:00
|
|
|
|
assert _receipt_log_count(writer["username"], success=False) == base_fail + 1
|
|
|
|
|
|
assert _receipt_log_count(writer["username"], success=True) == base_ok
|