安全:隔离主数据并封闭单条删除

This commit is contained in:
焦龙言
2026-09-22 18:16:30 +08:00
parent af5428d0e3
commit b6f606d9f2
13 changed files with 1382 additions and 1131 deletions
@@ -1,16 +1,18 @@
import secrets
from datetime import datetime
from io import BytesIO
from typing import Literal
from typing import Literal, NoReturn
import qrcode
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel, PageModel
from exceptions.exception import ServiceException
from exceptions.exception import ServiceException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao
from module_business.dao.master_dao import MasterDao
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizPalletInventory
from module_business.entity.vo.master_vo import (
@@ -22,6 +24,7 @@ from module_business.entity.vo.master_vo import (
PalletPageQueryModel,
UserAssignModel,
)
from module_business.service.test_batch_access_service import TestBatchAccessService
AssignmentResource = Literal['warehouse', 'site']
@@ -41,33 +44,43 @@ class PalletService:
qrcode.make(qr_token).save(buffer, format='PNG')
return buffer.getvalue()
@staticmethod
async def _prepare_test_write(
db: AsyncSession,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> None:
if scope.mode != 'TEST':
return
TestBatchAccessService.assert_super_admin(current_user)
await TestBatchAccessService.lock_active_batch(db, scope)
@classmethod
async def get_pallet_qrcode(cls, db: AsyncSession, pallet_id: int) -> bytes:
pallet = await MasterDao.get_pallet(db, pallet_id)
async def get_pallet_qrcode(cls, db: AsyncSession, pallet_id: int, scope: TestBatchScope) -> bytes:
pallet = await MasterDao.get_pallet(db, pallet_id, scope)
if pallet is None:
raise ServiceException(message='托盘不存在')
assert_record_in_scope(pallet, scope, '托盘')
return cls.render_qr_png(pallet.qr_token)
@staticmethod
def _extract_pallet_ids(value: int | DeletePalletModel) -> list[int]:
pallet_ids = [value] if isinstance(value, int) else list(value.pallet_ids)
if not pallet_ids or any(pallet_id <= 0 for pallet_id in pallet_ids):
raise ServiceException(message='托盘ID必须为正整数')
if len(pallet_ids) != len(set(pallet_ids)):
raise ServiceException(message='托盘ID不能重复')
return sorted(pallet_ids)
@classmethod
async def _lock_pallet_inventory(cls, db: AsyncSession, pallet_id: int) -> tuple[BizPallet, BizPalletInventory]:
async def _lock_pallet_inventory(
cls,
db: AsyncSession,
pallet_id: int,
scope: TestBatchScope,
) -> tuple[BizPallet, BizPalletInventory]:
"""按库存、托盘顺序加锁,避免与流转事务形成反序死锁。"""
inventory = await MasterDao.get_pallet_inventory(db, pallet_id, for_update=True)
inventory = await MasterDao.get_pallet_inventory(db, pallet_id, scope, for_update=True)
if inventory is None:
if await MasterDao.get_pallet(db, pallet_id) is None:
if await MasterDao.get_pallet(db, pallet_id, scope) is None:
raise ServiceException(message='托盘不存在')
raise ServiceException(message='托盘库存不存在')
pallet = await MasterDao.get_pallet(db, pallet_id, for_update=True)
assert_record_in_scope(inventory, scope, '托盘库存')
pallet = await MasterDao.get_pallet(db, pallet_id, scope, for_update=True)
if pallet is None:
raise ServiceException(message='托盘不存在')
assert_record_in_scope(pallet, scope, '托盘')
return pallet, inventory
@classmethod
@@ -75,42 +88,41 @@ class PalletService:
cls,
db: AsyncSession,
query_object: PalletPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[PalletModel]:
result = await MasterDao.list_pallet(db, query_object, is_page)
result = await MasterDao.list_pallet(db, query_object, scope, is_page)
records = result.rows if isinstance(result, PageModel) else result
models = [PalletModel.model_validate(record) for record in records]
removability = await MasterDao.get_pallet_removability(
db,
[model.pallet_id for model in models],
)
rows = [model.model_copy(update={'can_remove': removability.get(model.pallet_id, False)}) for model in models]
rows = [PalletModel.model_validate(record) for record in records]
if isinstance(result, PageModel):
return result.model_copy(update={'rows': rows})
return rows
@classmethod
async def detail_pallet(cls, db: AsyncSession, pallet_id: int) -> PalletModel:
pallet = await MasterDao.get_pallet(db, pallet_id)
async def detail_pallet(cls, db: AsyncSession, pallet_id: int, scope: TestBatchScope) -> PalletModel:
pallet = await MasterDao.get_pallet(db, pallet_id, scope)
if pallet is None:
raise ServiceException(message='托盘不存在')
model = PalletModel.model_validate(pallet)
removability = await MasterDao.get_pallet_removability(db, [pallet_id])
return model.model_copy(update={'can_remove': removability.get(pallet_id, False)})
assert_record_in_scope(pallet, scope, '托盘')
return PalletModel.model_validate(pallet)
@classmethod
async def add_pallet(
cls,
db: AsyncSession,
pallet: PalletCreateModel,
current_user_name: str,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
try:
await cls._prepare_test_write(db, current_user, scope)
if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None:
raise ServiceException(message='托盘编号已存在')
current_time = datetime.now()
current_user_name = current_user.user.user_name
pallet_record = BizPallet(
test_batch_id=scope.batch_id,
pallet_code=pallet.pallet_code,
qr_token=secrets.token_urlsafe(32),
capacity_packages=pallet.capacity_packages,
@@ -124,6 +136,7 @@ class PalletService:
pallet_record = await MasterDao.add_pallet(db, pallet_record)
inventory = BizPalletInventory(
pallet_id=pallet_record.pallet_id,
test_batch_id=scope.batch_id,
flow_state='UNSTOCKED',
remaining_packages=0,
version=0,
@@ -144,16 +157,19 @@ class PalletService:
cls,
db: AsyncSession,
pallet: PalletEditModel,
current_user_name: str,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
try:
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id)
await cls._prepare_test_write(db, current_user, scope)
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
if pallet.capacity_packages < inventory.remaining_packages:
raise ServiceException(message='托盘容量不能小于当前剩余包数')
await MasterDao.update_pallet(
db,
pallet,
update_by=current_user_name,
scope,
update_by=current_user.user.user_name,
update_time=datetime.now(),
)
await db.commit()
@@ -170,27 +186,8 @@ class PalletService:
cls,
db: AsyncSession,
value: int | DeletePalletModel,
) -> CrudResponseModel:
try:
pallet_ids = cls._extract_pallet_ids(value)
for pallet_id in pallet_ids:
await cls._lock_pallet_inventory(db, pallet_id)
removability = await MasterDao.get_pallet_removability(db, pallet_ids)
if not all(removability.get(pallet_id, False) for pallet_id in pallet_ids):
raise ServiceException(message='托盘状态或关联数据不允许删除')
for pallet_id in pallet_ids:
await MasterDao.delete_pallet_inventory(db, pallet_id)
await MasterDao.delete_pallet(db, pallet_id)
await db.commit()
return CrudResponseModel(is_success=True, message='删除成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='删除托盘失败,托盘状态或关联数据已变化') from None
except Exception:
await db.rollback()
raise
) -> NoReturn:
raise ServiceWarning('', '不支持单条物理删除,请通过测试数据批次清理')
@classmethod
async def _list_resource_users(
@@ -198,12 +195,17 @@ class PalletService:
db: AsyncSession,
resource: AssignmentResource,
resource_id: int,
scope: TestBatchScope,
) -> list[BusinessUserOptionModel]:
if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None:
record = await getattr(MasterDao, f'get_{resource}')(db, resource_id, scope)
if record is None:
raise ServiceException(message=f'{_ASSIGNMENT_LABELS[resource]}不存在')
assert_record_in_scope(record, scope, _ASSIGNMENT_LABELS[resource])
users = await MasterDao.list_assignable_users(db)
assigned_user_ids = set(await getattr(MasterDao, f'list_{resource}_assigned_user_ids')(db, resource_id))
assigned_user_ids = set(
await getattr(MasterDao, f'list_{resource}_assigned_user_ids')(db, resource_id, scope)
)
return [
BusinessUserOptionModel(
user_id=user.user_id,
@@ -219,16 +221,18 @@ class PalletService:
cls,
db: AsyncSession,
warehouse_id: int,
scope: TestBatchScope,
) -> list[BusinessUserOptionModel]:
return await cls._list_resource_users(db, 'warehouse', warehouse_id)
return await cls._list_resource_users(db, 'warehouse', warehouse_id, scope)
@classmethod
async def list_site_users(
cls,
db: AsyncSession,
site_id: int,
scope: TestBatchScope,
) -> list[BusinessUserOptionModel]:
return await cls._list_resource_users(db, 'site', site_id)
return await cls._list_resource_users(db, 'site', site_id, scope)
@classmethod
async def _assign_resource_users(
@@ -237,18 +241,21 @@ class PalletService:
resource: AssignmentResource,
resource_id: int,
assignment: UserAssignModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
label = _ASSIGNMENT_LABELS[resource]
try:
if (
await getattr(MasterDao, f'get_{resource}')(
db,
resource_id,
for_update=True,
)
is None
):
await cls._prepare_test_write(db, current_user, scope)
record = await getattr(MasterDao, f'get_{resource}')(
db,
resource_id,
scope,
for_update=True,
)
if record is None:
raise ServiceException(message=f'{label}不存在')
assert_record_in_scope(record, scope, label)
users = await MasterDao.list_assignable_users_by_ids(db, assignment.user_ids)
if {user.user_id for user in users} != set(assignment.user_ids):
raise ServiceException(message='存在不存在、停用或已删除的用户')
@@ -268,8 +275,17 @@ class PalletService:
db: AsyncSession,
warehouse_id: int,
assignment: UserAssignModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._assign_resource_users(db, 'warehouse', warehouse_id, assignment)
return await cls._assign_resource_users(
db,
'warehouse',
warehouse_id,
assignment,
current_user,
scope,
)
@classmethod
async def assign_site_users(
@@ -277,5 +293,14 @@ class PalletService:
db: AsyncSession,
site_id: int,
assignment: UserAssignModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._assign_resource_users(db, 'site', site_id, assignment)
return await cls._assign_resource_users(
db,
'site',
site_id,
assignment,
current_user,
scope,
)