修复:限定幂等键精确业务域

This commit is contained in:
焦龙言
2026-07-27 18:54:50 +08:00
parent 4a321098f8
commit b6f3b1e1f0
18 changed files with 730 additions and 135 deletions
@@ -729,6 +729,25 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertTrue(result.is_success)
self.assertEqual(31, result.result)
async def test_idempotency_lookup_uses_requested_warehouse_for_multi_scope_and_admin(self) -> None:
for user, scope in ((self.user, [9, 7, 9]), (make_user(1), None)):
with self.subTest(admin=bool(user.user and user.user.admin)):
self.db.reset_mock()
existing = make_filling()
get_key = AsyncMock(return_value=existing)
scope_read = AsyncMock(return_value=scope)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope_read),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=get_key),
):
result = await FillingService.fill_pallet_services(self.db, self.page, user)
get_key.assert_awaited_once_with(self.db, 'fill-key', warehouse_ids=[7])
self.assertEqual(31, result.result)
if user.user and user.user.admin:
scope_read.assert_not_awaited()
async def test_same_idempotency_key_with_different_hash_is_rejected(self) -> None:
existing = make_filling(request_hash='different-hash')
with (
@@ -913,7 +932,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
key_reads = AsyncMock(side_effect=[None, recovered])
access = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 9])),
patch.object(AccessService, 'assert_warehouse_access', new=access),
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=key_reads),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
@@ -926,6 +945,10 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
self.assertEqual(2, key_reads.await_count)
self.assertEqual(
[{'warehouse_ids': [7]}, {'warehouse_ids': [7]}],
[call.kwargs for call in key_reads.await_args_list],
)
self.assertEqual(2, self.db.rollback.await_count)
access.assert_any_await(self.db, self.user, 7)
self.assertTrue(result.is_success)
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI, Request
from fastapi.routing import APIRoute
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, MultipleResultsFound
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.interface_auth import CheckUserInterfaceAuth
@@ -1407,6 +1407,65 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.db.rollback.assert_awaited_once()
self.assertTrue(result.is_success)
async def test_same_key_retry_uses_exact_warehouse_when_user_can_access_duplicate_keys(self) -> None:
target = make_inbound(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
other_domain = make_inbound(
inbound_id=12,
warehouse_id=8,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=InboundService._build_confirm_request_hash(12),
)
async def read_key(
_db: AsyncSession,
_key: str,
*,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None:
matches = [
inbound
for inbound in (target, other_domain)
if warehouse_ids is None or inbound.warehouse_id in warehouse_ids
]
if len(matches) > 1:
raise MultipleResultsFound('广域幂等键查询返回多条入库单')
return matches[0] if matches else None
get_detail = AsyncMock(return_value=target)
key_read = AsyncMock(side_effect=read_key)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=target)),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
result = await InboundService.confirm_inbound_services(
self.db,
11,
self.command,
self.user,
)
self.assertTrue(result.is_success)
get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[7, 8])
key_read.assert_awaited_once_with(
self.db,
'confirm-key',
warehouse_ids=[7],
)
self.db.rollback.assert_awaited_once()
async def test_same_key_retry_hides_document_when_current_mapping_is_revoked(self) -> None:
confirmed = make_inbound(
status='CONFIRMED',
@@ -1569,6 +1628,76 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(2, self.db.rollback.await_count)
self.assertTrue(result.is_success)
async def test_integrity_recovery_uses_exact_warehouse_with_duplicate_visible_keys(self) -> None:
initial = make_inbound(status='DRAFT')
recovered = make_inbound(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
other_domain = make_inbound(
inbound_id=12,
warehouse_id=8,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=InboundService._build_confirm_request_hash(12),
)
read_count = 0
async def read_key(
_db: AsyncSession,
_key: str,
*,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder | None:
nonlocal read_count
read_count += 1
if read_count == 1:
return None
matches = [
inbound
for inbound in (recovered, other_domain)
if warehouse_ids is None or inbound.warehouse_id in warehouse_ids
]
if len(matches) > 1:
raise MultipleResultsFound('广域幂等键恢复查询返回多条入库单')
return matches[0] if matches else None
warehouse_scope = AsyncMock(side_effect=[[7, 8], [7, 8]])
key_read = AsyncMock(side_effect=read_key)
parent_lock = AsyncMock(side_effect=[integrity_error(), recovered])
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=initial)),
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_inbound_for_update', new=parent_lock),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(return_value=SimpleNamespace(warehouse_id=7, status='0')),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
result = await InboundService.confirm_inbound_services(
self.db,
11,
self.command,
self.user,
)
self.assertTrue(result.is_success)
self.assertEqual(2, warehouse_scope.await_count)
self.assertEqual(2, parent_lock.await_count)
self.assertEqual(
[
unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]),
unittest.mock.call(self.db, 'confirm-key', warehouse_ids=[7]),
],
key_read.await_args_list,
)
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_integrity_recovery_locks_parent_and_rechecks_revoked_current_mapping(self) -> None:
inbound = make_inbound(pallet_ids=(2,))
recovered = make_inbound(
@@ -141,14 +141,14 @@ class TestDaoPublicContract(unittest.TestCase):
'get_return_by_request_idempotency_key': (
'db',
'idempotency_key',
'site_ids',
'warehouse_ids',
'source_site_id',
'target_warehouse_id',
),
'get_return_by_confirm_idempotency_key': (
'db',
'idempotency_key',
'site_ids',
'warehouse_ids',
'source_site_id',
'target_warehouse_id',
),
'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'),
'add_inbound': ('db', 'inbound'),
@@ -249,8 +249,6 @@ class TestDaoPublicContract(unittest.TestCase):
self.assertIs(True, return_list_signature.parameters['is_page'].default)
for method_name in (
'get_return_for_update',
'get_return_by_request_idempotency_key',
'get_return_by_confirm_idempotency_key',
'get_return_by_id',
'get_return_list',
):
@@ -259,6 +257,15 @@ class TestDaoPublicContract(unittest.TestCase):
self.assertIsNone(signature.parameters['site_ids'].default)
self.assertIsNone(signature.parameters['warehouse_ids'].default)
for method_name in (
'get_return_by_request_idempotency_key',
'get_return_by_confirm_idempotency_key',
):
with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name))
self.assertIs(inspect.Parameter.empty, signature.parameters['source_site_id'].default)
self.assertIs(inspect.Parameter.empty, signature.parameters['target_warehouse_id'].default)
def test_dao_source_never_controls_transaction_boundary(self) -> None:
for module in (inventory_dao_module, operation_dao_module):
with self.subTest(module=module.__name__):
@@ -609,26 +616,30 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
OperationDao.get_inbound_by_confirm_idempotency_key,
BizInboundOrder,
'confirm_idempotency_key',
{},
),
(
OperationDao.get_shipment_by_confirm_idempotency_key,
BizShipmentOrder,
'confirm_idempotency_key',
{},
),
(OperationDao.get_filling_by_idempotency_key, BizFillingRecord, 'idempotency_key'),
(OperationDao.get_usage_by_idempotency_key, BizUsageRecord, 'idempotency_key'),
(OperationDao.get_filling_by_idempotency_key, BizFillingRecord, 'idempotency_key', {}),
(OperationDao.get_usage_by_idempotency_key, BizUsageRecord, 'idempotency_key', {}),
(
OperationDao.get_return_by_request_idempotency_key,
BizReturnOrder,
'request_idempotency_key',
{'source_site_id': 9, 'target_warehouse_id': 7},
),
(
OperationDao.get_return_by_confirm_idempotency_key,
BizReturnOrder,
'confirm_idempotency_key',
{'source_site_id': 9, 'target_warehouse_id': 7},
),
)
for method, model, key_column in cases:
for method, model, key_column, kwargs in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
record = SimpleNamespace()
@@ -636,13 +647,17 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
result.scalar_one_or_none.return_value = record
self.db.execute.return_value = result
actual = await method(self.db, 'same-key')
actual = await method(self.db, 'same-key', **kwargs)
self.assertIs(record, actual)
statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement)
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
self.assertIn(f"{model.__tablename__}.{key_column} = 'same-key'", sql)
if model is BizReturnOrder:
self.assertIn('biz_return_order.source_site_id = 9', sql)
self.assertIn('biz_return_order.target_warehouse_id = 7', sql)
self.assertNotIn(' OR ', sql)
self.assertNotIn('FOR UPDATE', sql)
async def test_inbound_confirm_key_pushes_warehouse_scope_into_sql(self) -> None:
@@ -862,27 +862,27 @@ EXPECTED_UNIQUE_SETS = {
'biz_user_site': set(),
'biz_inbound_order': {
frozenset({'inbound_no'}),
frozenset({'confirm_idempotency_key'}),
frozenset({'warehouse_id', 'confirm_idempotency_key'}),
},
'biz_inbound_item': {frozenset({'inbound_id', 'pallet_id'})},
'biz_filling_record': {
frozenset({'filling_no'}),
frozenset({'idempotency_key'}),
frozenset({'warehouse_id', 'idempotency_key'}),
frozenset({'pallet_id', 'active_slot'}),
},
'biz_shipment_order': {
frozenset({'shipment_no'}),
frozenset({'confirm_idempotency_key'}),
frozenset({'source_warehouse_id', 'target_site_id', 'confirm_idempotency_key'}),
},
'biz_shipment_item': {frozenset({'shipment_id', 'pallet_id'})},
'biz_usage_record': {
frozenset({'usage_no'}),
frozenset({'idempotency_key'}),
frozenset({'site_id', 'idempotency_key'}),
},
'biz_return_order': {
frozenset({'return_no'}),
frozenset({'request_idempotency_key'}),
frozenset({'confirm_idempotency_key'}),
frozenset({'source_site_id', 'target_warehouse_id', 'request_idempotency_key'}),
frozenset({'source_site_id', 'target_warehouse_id', 'confirm_idempotency_key'}),
},
'biz_return_item': {frozenset({'return_id', 'pallet_id'})},
'biz_pallet_inventory': set(),
@@ -188,22 +188,23 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase):
OperationDao.get_return_by_request_idempotency_key: (
'db',
'idempotency_key',
'site_ids',
'warehouse_ids',
'source_site_id',
'target_warehouse_id',
),
OperationDao.get_return_by_confirm_idempotency_key: (
'db',
'idempotency_key',
'site_ids',
'warehouse_ids',
'source_site_id',
'target_warehouse_id',
),
OperationDao.get_return_by_id: ('db', 'return_id', 'site_ids', 'warehouse_ids'),
}
for method, parameters in expected.items():
with self.subTest(method=method.__name__):
self.assertEqual(parameters, tuple(inspect.signature(method).parameters))
self.assertIsNone(inspect.signature(method).parameters['site_ids'].default)
self.assertIsNone(inspect.signature(method).parameters['warehouse_ids'].default)
if 'site_ids' in parameters:
self.assertIsNone(inspect.signature(method).parameters['site_ids'].default)
self.assertIsNone(inspect.signature(method).parameters['warehouse_ids'].default)
list_signature = inspect.signature(OperationDao.get_return_list)
self.assertEqual(
@@ -278,25 +279,40 @@ class ReturnDaoScopeRedTest(unittest.IsolatedAsyncioTestCase):
self.assertIn('biz_return_order.target_warehouse_id IN (7)', sql)
self.assertIn(' OR ', sql)
async def test_request_key_read_is_scoped_and_never_locks_missing_unique_key(self) -> None:
async def test_return_key_reads_use_exact_route_and_never_lock_missing_unique_key(self) -> None:
result = MagicMock()
result.scalar_one_or_none.return_value = None
self.db.execute.return_value = result
await OperationDao.get_return_by_request_idempotency_key(
self.db,
'request-key',
site_ids=[9],
warehouse_ids=[],
key_reads = (
(OperationDao.get_return_by_request_idempotency_key, 'request-key', 'request_idempotency_key'),
(OperationDao.get_return_by_confirm_idempotency_key, 'confirm-key', 'confirm_idempotency_key'),
)
for method, key, column in key_reads:
for source_site_id, target_warehouse_id in ((9, 7), (9, 8), (10, 7)):
with self.subTest(
method=method.__name__,
source_site_id=source_site_id,
target_warehouse_id=target_warehouse_id,
):
self.db.reset_mock()
self.db.execute.return_value = result
await method(
self.db,
key,
source_site_id=source_site_id,
target_warehouse_id=target_warehouse_id,
)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizReturnOrder.__table__))
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect)
self.assertIn("biz_return_order.request_idempotency_key = 'request-key'", sql)
self.assertIn('biz_return_order.source_site_id IN (9)', sql)
self.assertNotIn('FOR UPDATE', sql)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizReturnOrder.__table__))
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect)
self.assertIn(f"biz_return_order.{column} = '{key}'", sql)
self.assertIn(f'biz_return_order.source_site_id = {source_site_id}', sql)
self.assertIn(f'biz_return_order.target_warehouse_id = {target_warehouse_id}', sql)
self.assertNotIn(' OR ', sql)
self.assertNotIn('FOR UPDATE', sql)
@unittest.skipUnless(ReturnService is not None, 'ReturnService 尚未实现')
@@ -1021,8 +1037,8 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(13, result.result)
self.assertEqual(
[
{'site_ids': [9], 'warehouse_ids': []},
{'site_ids': [9, 10], 'warehouse_ids': []},
{'source_site_id': 9, 'target_warehouse_id': 7},
{'source_site_id': 9, 'target_warehouse_id': 7},
],
[call.kwargs for call in key_read.await_args_list],
)
@@ -1276,7 +1292,10 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(2, scope_read.await_count)
self.assertEqual(2, key_read.await_count)
self.assertEqual(
[{'site_ids': [9], 'warehouse_ids': []}, {'site_ids': [9], 'warehouse_ids': []}],
[
{'source_site_id': 9, 'target_warehouse_id': 7},
{'source_site_id': 9, 'target_warehouse_id': 7},
],
[call.kwargs for call in key_read.await_args_list],
)
locks.assert_awaited_once_with(self.db, [2, 5])
@@ -1447,7 +1466,41 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
inventory_locks.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_admin_request_idempotency_scope_is_unrestricted(self) -> None:
async def test_request_integrity_recovery_key_lookup_uses_exact_route(self) -> None:
request_hash = ReturnService._build_request_hash(self.page)
exact = make_return_order(request_hash=request_hash)
key_read = AsyncMock(return_value=exact)
order_lock = AsyncMock(return_value=exact)
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[10, 9])),
patch.object(OperationDao, 'get_return_by_request_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await ReturnService._recover_request_integrity_error(
self.db,
self.page,
request_hash,
self.user,
)
self.assertEqual(13, result.result)
key_read.assert_awaited_once_with(
self.db,
'request-key',
source_site_id=9,
target_warehouse_id=7,
)
order_lock.assert_awaited_once_with(
self.db,
13,
site_ids=[9, 10],
warehouse_ids=[],
)
self.db.rollback.assert_awaited_once()
async def test_admin_request_idempotency_lookup_still_uses_exact_route(self) -> None:
admin = make_user(1)
request_hash = ReturnService._build_request_hash(self.page)
exact = make_return_order(request_hash=request_hash)
@@ -1464,8 +1517,8 @@ class ReturnRequestTransactionRedTest(unittest.IsolatedAsyncioTestCase):
get_by_key.assert_awaited_once_with(
self.db,
'request-key',
site_ids=None,
warehouse_ids=None,
source_site_id=9,
target_warehouse_id=7,
)
@@ -1558,7 +1611,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([None, None], [event.after_site_id for event in events])
self.db.commit.assert_awaited_once()
async def test_confirm_preflights_idempotency_key_before_return_id(self) -> None:
async def test_confirm_resolves_authorized_return_route_before_key_lookup(self) -> None:
call_order: list[str] = []
order = make_return_order()
@@ -1569,9 +1622,10 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
call_order.append('return')
return order
key_read = AsyncMock(side_effect=get_by_key)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=AsyncMock(side_effect=get_by_key)),
patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(side_effect=get_by_id)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=order)),
@@ -1583,7 +1637,13 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
except ServiceException:
pass
self.assertEqual(['key', 'return'], call_order[:2])
self.assertEqual(['return', 'key'], call_order[:2])
key_read.assert_awaited_once_with(
self.db,
'confirm-key',
source_site_id=9,
target_warehouse_id=7,
)
async def test_confirm_requires_exact_scan_set_before_inventory_locks(self) -> None:
order = make_return_order()
@@ -1688,7 +1748,7 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertIn('幂等键已被其他请求使用', raised.exception.message)
conflict_db.rollback.assert_awaited()
async def test_admin_confirm_scope_is_unrestricted(self) -> None:
async def test_admin_confirm_idempotency_lookup_still_uses_exact_route(self) -> None:
admin = make_user(1)
confirm_hash = ReturnService._build_confirm_request_hash(13, self.page)
exact = make_return_order(
@@ -1697,12 +1757,13 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
confirm_hash=confirm_hash,
)
get_order = AsyncMock(return_value=exact)
get_by_key = AsyncMock(return_value=exact)
with (
patch.object(OperationDao, 'get_return_by_id', new=get_order),
patch.object(
OperationDao,
'get_return_by_confirm_idempotency_key',
new=AsyncMock(return_value=exact),
new=get_by_key,
),
patch.object(OperationDao, 'get_return_for_update', new=AsyncMock(return_value=exact)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
@@ -1717,6 +1778,60 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
site_ids=None,
warehouse_ids=None,
)
get_by_key.assert_awaited_once_with(
self.db,
'confirm-key',
source_site_id=9,
target_warehouse_id=7,
)
async def test_confirm_integrity_recovery_resolves_route_before_exact_key_lookup(self) -> None:
confirm_hash = ReturnService._build_confirm_request_hash(13, self.page)
exact = make_return_order(
status=ReturnStatus.CONFIRMED.value,
confirm_key='confirm-key',
confirm_hash=confirm_hash,
)
call_order: list[str] = []
async def read_route(*_args: Any, **_kwargs: Any) -> BizReturnOrder:
call_order.append('return')
return exact
async def read_key(*_args: Any, **_kwargs: Any) -> BizReturnOrder:
call_order.append('key')
return exact
route_read = AsyncMock(side_effect=read_route)
key_read = AsyncMock(side_effect=read_key)
order_lock = AsyncMock(return_value=exact)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[8, 7])),
patch.object(OperationDao, 'get_return_by_id', new=route_read),
patch.object(OperationDao, 'get_return_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_return_for_update', new=order_lock),
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace(status='0'))),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
result = await ReturnService._recover_confirm_integrity_error(
self.db,
13,
self.page,
confirm_hash,
self.user,
)
self.assertEqual(13, result.result)
self.assertEqual(['return', 'key'], call_order)
route_read.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7, 8])
key_read.assert_awaited_once_with(
self.db,
'confirm-key',
source_site_id=9,
target_warehouse_id=7,
)
order_lock.assert_awaited_once_with(self.db, 13, site_ids=[], warehouse_ids=[7, 8])
self.db.rollback.assert_awaited_once()
async def test_confirm_rechecks_warehouse_mapping_before_business_locks(self) -> None:
order = make_return_order()
@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from fastapi.routing import APIRoute
from sqlalchemy.dialects import postgresql
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, MultipleResultsFound
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.interface_auth import CheckUserInterfaceAuth
@@ -2181,6 +2181,96 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
parent_lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_same_key_retry_uses_exact_route_when_user_can_access_duplicate_keys(self) -> None:
target = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
other_domain = make_shipment(
13,
source_warehouse_id=8,
target_site_id=10,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=ShipmentService._build_confirm_request_hash(13),
)
async def read_key(
_db: AsyncSession,
_key: str,
*,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> BizShipmentOrder | None:
matches = [
shipment
for shipment in (target, other_domain)
if (warehouse_ids is None or shipment.source_warehouse_id in warehouse_ids)
and (site_ids is None or shipment.target_site_id in site_ids)
]
if len(matches) > 1:
raise MultipleResultsFound('广域幂等键查询返回多条发货单')
return matches[0] if matches else None
get_detail = AsyncMock(return_value=target)
key_read = AsyncMock(side_effect=read_key)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 8])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 10])),
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
patch.object(OperationDao, 'get_shipment_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=target)),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
)
self.assertTrue(result.is_success)
get_detail.assert_awaited_once_with(
self.db,
12,
warehouse_ids=[7, 8],
site_ids=[9, 10],
)
key_read.assert_awaited_once_with(
self.db,
'confirm-key',
warehouse_ids=[7],
site_ids=[9],
)
self.db.rollback.assert_awaited_once()
async def test_integrity_recovery_nonexact_result_is_stable_conflict_not_success(self) -> None:
initial = make_shipment()
nonexact = make_shipment(
@@ -2413,6 +2503,113 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_integrity_recovery_uses_exact_route_with_duplicate_visible_keys(self) -> None:
initial = make_shipment(status='DRAFT')
recovered = make_shipment(
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=self.request_hash,
)
other_domain = make_shipment(
13,
source_warehouse_id=8,
target_site_id=10,
status='CONFIRMED',
idempotency_key='confirm-key',
request_hash=ShipmentService._build_confirm_request_hash(13),
)
read_count = 0
async def read_key(
_db: AsyncSession,
_key: str,
*,
warehouse_ids: list[int] | None = None,
site_ids: list[int] | None = None,
) -> BizShipmentOrder | None:
nonlocal read_count
read_count += 1
if read_count == 1:
return None
matches = [
shipment
for shipment in (recovered, other_domain)
if (warehouse_ids is None or shipment.source_warehouse_id in warehouse_ids)
and (site_ids is None or shipment.target_site_id in site_ids)
]
if len(matches) > 1:
raise MultipleResultsFound('广域幂等键恢复查询返回多条发货单')
return matches[0] if matches else None
warehouse_scope = AsyncMock(side_effect=[[7, 8], [7, 8]])
site_scope = AsyncMock(side_effect=[[9, 10], [9, 10]])
key_read = AsyncMock(side_effect=read_key)
parent_lock = AsyncMock(side_effect=[integrity_error(), recovered])
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
patch.object(OperationDao, 'get_shipment_by_id', new=AsyncMock(return_value=initial)),
patch.object(OperationDao, 'get_shipment_by_confirm_idempotency_key', new=key_read),
patch.object(OperationDao, 'get_shipment_for_update', new=parent_lock),
patch.object(
MasterDao,
'get_warehouse',
new=AsyncMock(
return_value=BizWarehouse(
warehouse_id=7,
warehouse_code='W-7',
warehouse_name='七号仓',
status='0',
)
),
),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(
return_value=BizCustomerSite(
site_id=9,
customer_id=1,
site_code='S-9',
site_name='九号现场',
status='0',
)
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await ShipmentService.confirm_shipment_services(
self.db,
12,
self.command,
self.user,
)
self.assertTrue(result.is_success)
self.assertEqual(2, warehouse_scope.await_count)
self.assertEqual(2, site_scope.await_count)
self.assertEqual(2, parent_lock.await_count)
self.assertEqual(
[
unittest.mock.call(
self.db,
'confirm-key',
warehouse_ids=[7],
site_ids=[9],
),
unittest.mock.call(
self.db,
'confirm-key',
warehouse_ids=[7],
site_ids=[9],
),
],
key_read.await_args_list,
)
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
async def test_revoked_mapping_after_locks_hides_document_and_mutates_nothing(self) -> None:
shipment = make_shipment(pallet_ids=(2,))
inventory = make_inventory(2, version=4)
@@ -218,13 +218,24 @@ EXPECTED_UNIQUE_KEYS = {
'biz_pallet': {('pallet_code',), ('qr_token',)},
'biz_user_warehouse': set(),
'biz_user_site': set(),
'biz_inbound_order': {('inbound_no',), ('confirm_idempotency_key',)},
'biz_inbound_order': {('inbound_no',), ('warehouse_id', 'confirm_idempotency_key')},
'biz_inbound_item': {('inbound_id', 'pallet_id')},
'biz_filling_record': {('filling_no',), ('idempotency_key',), ('pallet_id', 'active_slot')},
'biz_shipment_order': {('shipment_no',), ('confirm_idempotency_key',)},
'biz_filling_record': {
('filling_no',),
('warehouse_id', 'idempotency_key'),
('pallet_id', 'active_slot'),
},
'biz_shipment_order': {
('shipment_no',),
('source_warehouse_id', 'target_site_id', 'confirm_idempotency_key'),
},
'biz_shipment_item': {('shipment_id', 'pallet_id')},
'biz_usage_record': {('usage_no',), ('idempotency_key',)},
'biz_return_order': {('return_no',), ('request_idempotency_key',), ('confirm_idempotency_key',)},
'biz_usage_record': {('usage_no',), ('site_id', 'idempotency_key')},
'biz_return_order': {
('return_no',),
('source_site_id', 'target_warehouse_id', 'request_idempotency_key'),
('source_site_id', 'target_warehouse_id', 'confirm_idempotency_key'),
},
'biz_return_item': {('return_id', 'pallet_id')},
'biz_pallet_inventory': set(),
'biz_pallet_event': {('pallet_id', 'event_seq'), ('pallet_id', 'idempotency_key')},
@@ -732,8 +732,19 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
pallet_lock = AsyncMock(return_value=[selected_pallet])
filling_lock = AsyncMock(return_value=selected_filling)
site_lock = AsyncMock(return_value=selected_site)
preflight_line_read = AsyncMock(return_value=selected_line)
line_lock = AsyncMock(return_value=selected_line)
async def read_line(
db: AsyncSession,
line_id: int,
*,
for_update: bool = False,
) -> BizProductionLine | None:
if for_update:
return await line_lock(db, line_id, for_update=True)
return await preflight_line_read(db, line_id)
stack.enter_context(patch.object(AccessDao, 'list_user_site_ids', new=scope_read))
stack.enter_context(patch.object(AccessService, 'assert_site_access', new=site_access))
stack.enter_context(patch.object(OperationDao, 'get_usage_by_idempotency_key', new=usage_key_read))
@@ -742,7 +753,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
stack.enter_context(patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock))
stack.enter_context(patch.object(OperationDao, 'get_active_filling_for_update', new=filling_lock))
stack.enter_context(patch.object(MasterDao, 'get_site', new=site_lock))
stack.enter_context(patch.object(MasterDao, 'get_line', new=line_lock))
stack.enter_context(patch.object(MasterDao, 'get_line', new=AsyncMock(side_effect=read_line)))
stack.enter_context(patch.object(OperationDao, 'add_usage_record', new=usage_write))
stack.enter_context(patch.object(OperationDao, 'update_filling_record', new=filling_write))
stack.enter_context(patch.object(InventoryDao, 'add_event', new=event_write))
@@ -757,6 +768,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
'pallet_lock': pallet_lock,
'filling_lock': filling_lock,
'site_lock': site_lock,
'preflight_line_read': preflight_line_read,
'line_lock': line_lock,
'add_usage': usage_write,
'update_filling': filling_write,
@@ -877,6 +889,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
[
'preflight-scope',
'line-lock',
'usage-preflight-read',
'qr-snapshot-read',
'rollback',
@@ -1044,6 +1057,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
self.db.reset_mock()
stack, mocks = self._patch_record_path(line=line)
if line is None:
mocks['preflight_line_read'].return_value = None
mocks['line_lock'].return_value = None
with stack, self.assertRaises(ServiceException) as raised:
await UsageService.record_usage_services(self.db, self.page, self.user)
@@ -1169,6 +1183,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
async def test_record_hides_missing_and_non_site_qr_but_admin_gets_precise_error(self) -> None:
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
self.assertRaises(PermissionException) as normal_missing,
@@ -1179,6 +1194,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
self.db.reset_mock()
with (
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException) as admin_missing,
@@ -1239,10 +1255,11 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
async def read_key(*_args: Any, **kwargs: Any) -> BizUsageRecord | None:
nonlocal key_call_count
key_call_count += 1
actions.append('usage-preflight-read' if key_call_count == 1 else 'usage-fresh-read')
if key_call_count == 1:
is_locked_phase = 'inventory-lock' in actions
actions.append('usage-fresh-read' if is_locked_phase else 'usage-preflight-read')
if not is_locked_phase:
return None
return recovered if kwargs.get('site_ids') == scopes[1] else None
return recovered if kwargs.get('site_ids') == [inventory.site_id] else None
async def scan_pallet(*_args: Any) -> BizPallet:
actions.append('qr-snapshot-read')
@@ -1301,6 +1318,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
access = AsyncMock()
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(
OperationDao,
'get_usage_by_idempotency_key',
@@ -1334,10 +1352,34 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
result.result.model_dump(by_alias=True),
)
async def test_preflight_key_lookup_uses_line_site_for_multi_scope_and_admin(self) -> None:
for user, scope in ((self.user, [10, 9, 10]), (make_user(1), None)):
with self.subTest(admin=bool(user.user and user.user.admin)):
self.db.reset_mock()
existing = self._exact_usage()
line_read = AsyncMock(return_value=make_line(site_id=9))
get_key = AsyncMock(return_value=existing)
scope_read = AsyncMock(return_value=scope)
with (
patch.object(AccessDao, 'list_user_site_ids', new=scope_read),
patch.object(MasterDao, 'get_line', new=line_read),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=get_key),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())),
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
):
result = await UsageService.record_usage_services(self.db, self.page, user)
line_read.assert_awaited_once_with(self.db, 4)
get_key.assert_awaited_once_with(self.db, 'usage-key', site_ids=[9])
self.assertEqual(61, result.result.usage_id)
if user.user and user.user.admin:
scope_read.assert_not_awaited()
async def test_same_key_with_different_hash_is_stable_conflict(self) -> None:
existing = self._exact_usage(request_hash='different-hash')
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)),
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock()) as inventory_lock,
self.assertRaises(ServiceException) as raised,
@@ -1353,6 +1395,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(MasterDao, 'get_line', new=AsyncMock(return_value=make_line())),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=existing)),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=make_site())),
patch.object(AccessService, 'assert_site_access', new=access),
@@ -1505,7 +1548,6 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
[
'preflight-scope',
'usage-preflight-read',
'qr-snapshot-read',
'preflight-rollback',
'inventory-lock',
@@ -1520,7 +1562,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
actions,
)
self.assertEqual(
[{'site_ids': []}, {'site_ids': [9]}],
[{'site_ids': [9]}],
[call.kwargs for call in mocks['key_read'].await_args_list],
)
mocks['filling_lock'].assert_not_awaited()
@@ -1702,11 +1744,11 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
self.fail('锁后命中的摘要或实际现场冲突不得返回成功')
self.assertEqual(
[{'site_ids': [9, 10]}, {'site_ids': [9, 10]}],
[{'site_ids': [9]}, {'site_ids': [9]}],
[call.kwargs for call in key_read.await_args_list],
)
self.assertEqual(2, mocks['scope_read'].await_count)
self.assertTrue(all(call.kwargs['site_ids'] == [9, 10] for call in key_read.await_args_list))
self.assertTrue(all(call.kwargs['site_ids'] == [9] for call in key_read.await_args_list))
mocks['site_lock'].assert_not_awaited()
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
@@ -1717,7 +1759,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
async def test_integrity_error_recovers_only_fresh_scoped_exact_result(self) -> None:
recovered = self._exact_usage()
scope = AsyncMock(side_effect=[[9], [9], [9]])
scope = AsyncMock(side_effect=[[10, 9], [10, 9], [10, 9]])
key_read = AsyncMock(side_effect=[None, None, recovered])
add_usage = AsyncMock(side_effect=integrity_error())
stack, mocks = self.core._patch_record_path(