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

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,4 +1,4 @@
from typing import Any, Literal
from typing import Any, Literal, NoReturn
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
@@ -6,7 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel, PageModel
from exceptions.exception import ServiceException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel
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.vo.master_vo import (
CustomerModel,
CustomerPageQueryModel,
@@ -24,6 +26,7 @@ from module_business.entity.vo.master_vo import (
WarehouseModel,
WarehousePageQueryModel,
)
from module_business.service.test_batch_access_service import TestBatchAccessService
MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse']
@@ -71,17 +74,35 @@ class MasterService:
"""菌种、客户、现场、产线和仓库主数据服务层。"""
@staticmethod
async def _validate_parent(db: AsyncSession, resource: MasterResource, page_object: BaseModel) -> None:
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)
@staticmethod
async def _validate_parent(
db: AsyncSession,
resource: MasterResource,
page_object: BaseModel,
scope: TestBatchScope,
) -> None:
if resource == 'site':
customer = await MasterDao.get_customer(db, page_object.customer_id)
customer = await MasterDao.get_customer(db, page_object.customer_id, scope)
if customer is None:
raise ServiceException(message='目标客户不存在')
assert_record_in_scope(customer, scope, '目标客户')
if customer.status != '0':
raise ServiceException(message='目标客户已停用')
elif resource == 'line':
site = await MasterDao.get_site(db, page_object.site_id)
site = await MasterDao.get_site(db, page_object.site_id, scope)
if site is None:
raise ServiceException(message='目标客户现场不存在')
assert_record_in_scope(site, scope, '目标客户现场')
if site.status != '0':
raise ServiceException(message='目标客户现场已停用')
@@ -127,50 +148,37 @@ class MasterService:
code = getattr(page_object, config['code_field'])
raise ServiceException(message=f'{config["label"]}编码{code}已存在')
@staticmethod
def _extract_ids(resource: MasterResource, value: int | BaseModel) -> list[int]:
ids = [value] if isinstance(value, int) else list(getattr(value, _RESOURCE_CONFIG[resource]['ids_field']))
if not ids or any(resource_id <= 0 for resource_id in ids):
raise ServiceException(message='主数据ID必须为正整数')
if len(ids) != len(set(ids)):
raise ServiceException(message='主数据ID不能重复')
return ids
@classmethod
async def _list(
cls,
db: AsyncSession,
resource: MasterResource,
query_object: BaseModel,
scope: TestBatchScope,
is_page: bool,
) -> PageModel | list[BaseModel]:
result = await getattr(MasterDao, f'list_{resource}')(db, query_object, is_page)
result = await getattr(MasterDao, f'list_{resource}')(db, query_object, scope, is_page)
config = _RESOURCE_CONFIG[resource]
records = result.rows if isinstance(result, PageModel) else result
models = [config['vo'].model_validate(record) for record in records]
id_field = config['id_field']
removability = await MasterDao.get_master_removability(
db,
resource,
[getattr(model, id_field) for model in models],
)
rows = [
model.model_copy(update={'can_remove': removability.get(getattr(model, id_field), False)})
for model in models
]
rows = [config['vo'].model_validate(record) for record in records]
if isinstance(result, PageModel):
return result.model_copy(update={'rows': rows})
return rows
@classmethod
async def _detail(cls, db: AsyncSession, resource: MasterResource, resource_id: int) -> BaseModel:
async def _detail(
cls,
db: AsyncSession,
resource: MasterResource,
resource_id: int,
scope: TestBatchScope,
) -> BaseModel:
config = _RESOURCE_CONFIG[resource]
record = await getattr(MasterDao, f'get_{resource}')(db, resource_id)
record = await getattr(MasterDao, f'get_{resource}')(db, resource_id, scope)
if record is None:
raise ServiceException(message=f'{config["label"]}不存在')
model = config['vo'].model_validate(record)
removability = await MasterDao.get_master_removability(db, resource, [resource_id])
return model.model_copy(update={'can_remove': removability.get(resource_id, False)})
assert_record_in_scope(record, scope, config['label'])
return config['vo'].model_validate(record)
@classmethod
async def _add(
@@ -178,13 +186,16 @@ class MasterService:
db: AsyncSession,
resource: MasterResource,
page_object: BaseModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
config = _RESOURCE_CONFIG[resource]
try:
await cls._validate_parent(db, resource, page_object)
await cls._prepare_test_write(db, current_user, scope)
await cls._validate_parent(db, resource, page_object, scope)
await cls._validate_unique(db, resource, page_object)
safe_object = page_object.model_copy(update={config['id_field']: None})
await getattr(MasterDao, f'add_{resource}')(db, safe_object)
await getattr(MasterDao, f'add_{resource}')(db, safe_object, scope.batch_id)
await db.commit()
return CrudResponseModel(is_success=True, message='新增成功')
except IntegrityError:
@@ -200,18 +211,27 @@ class MasterService:
db: AsyncSession,
resource: MasterResource,
page_object: BaseModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
config = _RESOURCE_CONFIG[resource]
try:
await cls._prepare_test_write(db, current_user, scope)
resource_id = getattr(page_object, config['id_field'])
if resource_id is None:
raise ServiceException(message=f'{config["label"]}ID不能为空')
current = await getattr(MasterDao, f'get_{resource}')(db, resource_id)
current = await getattr(MasterDao, f'get_{resource}')(
db,
resource_id,
scope,
for_update=scope.mode == 'TEST',
)
if current is None:
raise ServiceException(message=f'{config["label"]}不存在')
await cls._validate_parent(db, resource, page_object)
assert_record_in_scope(current, scope, config['label'])
await cls._validate_parent(db, resource, page_object, scope)
await cls._validate_unique(db, resource, page_object, resource_id)
await getattr(MasterDao, f'update_{resource}')(db, page_object)
await getattr(MasterDao, f'update_{resource}')(db, page_object, scope)
await db.commit()
return CrudResponseModel(is_success=True, message='更新成功')
except IntegrityError:
@@ -221,173 +241,216 @@ class MasterService:
await db.rollback()
raise
@classmethod
async def _remove(
cls,
db: AsyncSession,
resource: MasterResource,
value: int | BaseModel,
) -> CrudResponseModel:
config = _RESOURCE_CONFIG[resource]
try:
resource_ids = cls._extract_ids(resource, value)
for resource_id in resource_ids:
if await MasterDao.count_references(db, resource, resource_id) > 0:
raise ServiceWarning(message=f'{config["label"]}已被引用,只能停用,不能删除')
if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None:
raise ServiceException(message=f'{config["label"]}不存在')
for resource_id in resource_ids:
await getattr(MasterDao, f'delete_{resource}')(db, resource_id)
await db.commit()
return CrudResponseModel(is_success=True, message='删除成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message=f'删除{config["label"]}失败,该数据仍被引用') from None
except Exception:
await db.rollback()
raise
@staticmethod
async def reject_physical_delete() -> NoReturn:
raise ServiceWarning('', '不支持单条物理删除,请通过测试数据批次清理')
@classmethod
async def list_strain(
cls,
db: AsyncSession,
query_object: StrainPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[StrainModel]:
return await cls._list(db, 'strain', query_object, is_page)
return await cls._list(db, 'strain', query_object, scope, is_page)
@classmethod
async def detail_strain(cls, db: AsyncSession, strain_id: int) -> StrainModel:
return await cls._detail(db, 'strain', strain_id)
async def detail_strain(cls, db: AsyncSession, strain_id: int, scope: TestBatchScope) -> StrainModel:
return await cls._detail(db, 'strain', strain_id, scope)
@classmethod
async def add_strain(cls, db: AsyncSession, strain: StrainModel) -> CrudResponseModel:
return await cls._add(db, 'strain', strain)
async def add_strain(
cls,
db: AsyncSession,
strain: StrainModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._add(db, 'strain', strain, current_user, scope)
@classmethod
async def edit_strain(cls, db: AsyncSession, strain: StrainModel) -> CrudResponseModel:
return await cls._edit(db, 'strain', strain)
async def edit_strain(
cls,
db: AsyncSession,
strain: StrainModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._edit(db, 'strain', strain, current_user, scope)
@classmethod
async def remove_strain(
cls,
db: AsyncSession,
value: int | DeleteStrainModel,
) -> CrudResponseModel:
return await cls._remove(db, 'strain', value)
) -> NoReturn:
return await cls.reject_physical_delete()
@classmethod
async def list_customer(
cls,
db: AsyncSession,
query_object: CustomerPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[CustomerModel]:
return await cls._list(db, 'customer', query_object, is_page)
return await cls._list(db, 'customer', query_object, scope, is_page)
@classmethod
async def detail_customer(cls, db: AsyncSession, customer_id: int) -> CustomerModel:
return await cls._detail(db, 'customer', customer_id)
async def detail_customer(cls, db: AsyncSession, customer_id: int, scope: TestBatchScope) -> CustomerModel:
return await cls._detail(db, 'customer', customer_id, scope)
@classmethod
async def add_customer(cls, db: AsyncSession, customer: CustomerModel) -> CrudResponseModel:
return await cls._add(db, 'customer', customer)
async def add_customer(
cls,
db: AsyncSession,
customer: CustomerModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._add(db, 'customer', customer, current_user, scope)
@classmethod
async def edit_customer(cls, db: AsyncSession, customer: CustomerModel) -> CrudResponseModel:
return await cls._edit(db, 'customer', customer)
async def edit_customer(
cls,
db: AsyncSession,
customer: CustomerModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._edit(db, 'customer', customer, current_user, scope)
@classmethod
async def remove_customer(
cls,
db: AsyncSession,
value: int | DeleteCustomerModel,
) -> CrudResponseModel:
return await cls._remove(db, 'customer', value)
) -> NoReturn:
return await cls.reject_physical_delete()
@classmethod
async def list_site(
cls,
db: AsyncSession,
query_object: SitePageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[SiteModel]:
return await cls._list(db, 'site', query_object, is_page)
return await cls._list(db, 'site', query_object, scope, is_page)
@classmethod
async def detail_site(cls, db: AsyncSession, site_id: int) -> SiteModel:
return await cls._detail(db, 'site', site_id)
async def detail_site(cls, db: AsyncSession, site_id: int, scope: TestBatchScope) -> SiteModel:
return await cls._detail(db, 'site', site_id, scope)
@classmethod
async def add_site(cls, db: AsyncSession, site: SiteModel) -> CrudResponseModel:
return await cls._add(db, 'site', site)
async def add_site(
cls,
db: AsyncSession,
site: SiteModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._add(db, 'site', site, current_user, scope)
@classmethod
async def edit_site(cls, db: AsyncSession, site: SiteModel) -> CrudResponseModel:
return await cls._edit(db, 'site', site)
async def edit_site(
cls,
db: AsyncSession,
site: SiteModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._edit(db, 'site', site, current_user, scope)
@classmethod
async def remove_site(
cls,
db: AsyncSession,
value: int | DeleteSiteModel,
) -> CrudResponseModel:
return await cls._remove(db, 'site', value)
) -> NoReturn:
return await cls.reject_physical_delete()
@classmethod
async def list_line(
cls,
db: AsyncSession,
query_object: LinePageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[LineModel]:
return await cls._list(db, 'line', query_object, is_page)
return await cls._list(db, 'line', query_object, scope, is_page)
@classmethod
async def detail_line(cls, db: AsyncSession, line_id: int) -> LineModel:
return await cls._detail(db, 'line', line_id)
async def detail_line(cls, db: AsyncSession, line_id: int, scope: TestBatchScope) -> LineModel:
return await cls._detail(db, 'line', line_id, scope)
@classmethod
async def add_line(cls, db: AsyncSession, line: LineModel) -> CrudResponseModel:
return await cls._add(db, 'line', line)
async def add_line(
cls,
db: AsyncSession,
line: LineModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._add(db, 'line', line, current_user, scope)
@classmethod
async def edit_line(cls, db: AsyncSession, line: LineModel) -> CrudResponseModel:
return await cls._edit(db, 'line', line)
async def edit_line(
cls,
db: AsyncSession,
line: LineModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._edit(db, 'line', line, current_user, scope)
@classmethod
async def remove_line(
cls,
db: AsyncSession,
value: int | DeleteLineModel,
) -> CrudResponseModel:
return await cls._remove(db, 'line', value)
) -> NoReturn:
return await cls.reject_physical_delete()
@classmethod
async def list_warehouse(
cls,
db: AsyncSession,
query_object: WarehousePageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[WarehouseModel]:
return await cls._list(db, 'warehouse', query_object, is_page)
return await cls._list(db, 'warehouse', query_object, scope, is_page)
@classmethod
async def detail_warehouse(cls, db: AsyncSession, warehouse_id: int) -> WarehouseModel:
return await cls._detail(db, 'warehouse', warehouse_id)
async def detail_warehouse(cls, db: AsyncSession, warehouse_id: int, scope: TestBatchScope) -> WarehouseModel:
return await cls._detail(db, 'warehouse', warehouse_id, scope)
@classmethod
async def add_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> CrudResponseModel:
return await cls._add(db, 'warehouse', warehouse)
async def add_warehouse(
cls,
db: AsyncSession,
warehouse: WarehouseModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._add(db, 'warehouse', warehouse, current_user, scope)
@classmethod
async def edit_warehouse(cls, db: AsyncSession, warehouse: WarehouseModel) -> CrudResponseModel:
return await cls._edit(db, 'warehouse', warehouse)
async def edit_warehouse(
cls,
db: AsyncSession,
warehouse: WarehouseModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._edit(db, 'warehouse', warehouse, current_user, scope)
@classmethod
async def remove_warehouse(
cls,
db: AsyncSession,
value: int | DeleteWarehouseModel,
) -> CrudResponseModel:
return await cls._remove(db, 'warehouse', value)
) -> NoReturn:
return await cls.reject_physical_delete()