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

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
@@ -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(