功能:新增仓库与客户现场数据权限

This commit is contained in:
焦龙言
2026-07-26 09:42:12 +08:00
parent 36e60af46b
commit 83a834f87e
3 changed files with 296 additions and 0 deletions
@@ -0,0 +1,64 @@
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_business.entity.do.master_do import BizUserSite, BizUserWarehouse
class AccessDao:
"""仓库与客户现场数据权限数据库操作层。"""
@classmethod
async def user_has_warehouse(cls, db: AsyncSession, user_id: int, warehouse_id: int) -> bool:
"""判断用户是否关联指定仓库。"""
mapped_user_id = (
await db.execute(
select(BizUserWarehouse.user_id).where(
BizUserWarehouse.user_id == user_id,
BizUserWarehouse.warehouse_id == warehouse_id,
)
)
).scalar_one_or_none()
return mapped_user_id is not None
@classmethod
async def user_has_site(cls, db: AsyncSession, user_id: int, site_id: int) -> bool:
"""判断用户是否关联指定客户现场。"""
mapped_user_id = (
await db.execute(
select(BizUserSite.user_id).where(
BizUserSite.user_id == user_id,
BizUserSite.site_id == site_id,
)
)
).scalar_one_or_none()
return mapped_user_id is not None
@classmethod
async def list_user_warehouse_ids(cls, db: AsyncSession, user_id: int) -> list[int]:
"""获取用户关联的仓库 ID 列表。"""
result = await db.execute(select(BizUserWarehouse.warehouse_id).where(BizUserWarehouse.user_id == user_id))
return list(result.scalars().all())
@classmethod
async def list_user_site_ids(cls, db: AsyncSession, user_id: int) -> list[int]:
"""获取用户关联的客户现场 ID 列表。"""
result = await db.execute(select(BizUserSite.site_id).where(BizUserSite.user_id == user_id))
return list(result.scalars().all())
@classmethod
async def replace_warehouse_users(cls, db: AsyncSession, warehouse_id: int, user_ids: list[int]) -> None:
"""完整替换指定仓库的用户关联,仅刷新当前事务。"""
await db.execute(delete(BizUserWarehouse).where(BizUserWarehouse.warehouse_id == warehouse_id))
db.add_all([BizUserWarehouse(user_id=user_id, warehouse_id=warehouse_id) for user_id in user_ids])
await db.flush()
@classmethod
async def replace_site_users(cls, db: AsyncSession, site_id: int, user_ids: list[int]) -> None:
"""完整替换指定客户现场的用户关联,仅刷新当前事务。"""
await db.execute(delete(BizUserSite).where(BizUserSite.site_id == site_id))
db.add_all([BizUserSite(user_id=user_id, site_id=site_id) for user_id in user_ids])
await db.flush()
@@ -0,0 +1,35 @@
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import PermissionException
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao
class AccessService:
"""仓库与客户现场数据权限服务层。"""
@classmethod
async def assert_warehouse_access(
cls,
db: AsyncSession,
current_user: CurrentUserModel,
warehouse_id: int,
) -> None:
"""校验当前用户是否有权访问指定仓库。"""
if current_user.user.admin:
return
if not await AccessDao.user_has_warehouse(db, current_user.user.user_id, warehouse_id):
raise PermissionException(data='', message='无权访问该仓库')
@classmethod
async def assert_site_access(
cls,
db: AsyncSession,
current_user: CurrentUserModel,
site_id: int,
) -> None:
"""校验当前用户是否有权访问指定客户现场。"""
if current_user.user.admin:
return
if not await AccessDao.user_has_site(db, current_user.user.user_id, site_id):
raise PermissionException(data='', message='无权访问该客户现场')
@@ -0,0 +1,197 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.sql import Select
from exceptions.exception import PermissionException
from module_business.dao.access_dao import AccessDao
from module_business.entity.do.master_do import BizUserSite, BizUserWarehouse
from module_business.service.access_service import AccessService
class AccessServiceTest(unittest.IsolatedAsyncioTestCase):
@patch('module_business.service.access_service.AccessDao.user_has_warehouse', new_callable=AsyncMock)
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_admin_bypasses_mapping(self, user_has_site: AsyncMock, user_has_warehouse: AsyncMock) -> None:
user = SimpleNamespace(user=SimpleNamespace(user_id=1, admin=True))
db = AsyncMock(spec=AsyncSession)
await AccessService.assert_warehouse_access(db, user, 99)
await AccessService.assert_site_access(db, user, 7)
user_has_warehouse.assert_not_awaited()
user_has_site.assert_not_awaited()
@patch('module_business.service.access_service.AccessDao.user_has_warehouse', new_callable=AsyncMock)
async def test_mapped_warehouse_is_allowed(self, user_has_warehouse: AsyncMock) -> None:
user_has_warehouse.return_value = True
user = SimpleNamespace(user=SimpleNamespace(user_id=8, admin=False))
db = AsyncMock(spec=AsyncSession)
await AccessService.assert_warehouse_access(db, user, 99)
user_has_warehouse.assert_awaited_once_with(db, 8, 99)
@patch('module_business.service.access_service.AccessDao.user_has_warehouse', new_callable=AsyncMock)
async def test_unmapped_warehouse_is_forbidden(self, user_has_warehouse: AsyncMock) -> None:
user_has_warehouse.return_value = False
user = SimpleNamespace(user=SimpleNamespace(user_id=8, admin=False))
with self.assertRaises(PermissionException) as raised:
await AccessService.assert_warehouse_access(AsyncMock(spec=AsyncSession), user, 99)
self.assertEqual('', raised.exception.data)
self.assertEqual('无权访问该仓库', raised.exception.message)
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_mapped_site_is_allowed(self, user_has_site: AsyncMock) -> None:
user_has_site.return_value = True
user = SimpleNamespace(user=SimpleNamespace(user_id=8, admin=False))
db = AsyncMock(spec=AsyncSession)
await AccessService.assert_site_access(db, user, 7)
user_has_site.assert_awaited_once_with(db, 8, 7)
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_unmapped_site_is_forbidden(self, user_has_site: AsyncMock) -> None:
user_has_site.return_value = False
user = SimpleNamespace(user=SimpleNamespace(user_id=8, admin=False))
with self.assertRaises(PermissionException) as raised:
await AccessService.assert_site_access(AsyncMock(spec=AsyncSession), user, 7)
self.assertEqual('', raised.exception.data)
self.assertEqual('无权访问该客户现场', raised.exception.message)
class AccessDaoTest(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _db_with_result(result: MagicMock) -> AsyncMock:
db = AsyncMock(spec=AsyncSession)
db.execute.return_value = result
return db
def _assert_statement_targets(
self,
statement: Select[tuple[int]],
model: type[BizUserWarehouse] | type[BizUserSite],
selected_column: InstrumentedAttribute[int],
expected_params: set[int],
) -> None:
self.assertTrue(statement.selected_columns[0].compare(selected_column.property.columns[0]))
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
self.assertEqual(expected_params, set(statement.compile().params.values()))
async def test_user_has_warehouse_returns_mapping_existence(self) -> None:
for scalar, expected in ((8, True), (None, False)):
with self.subTest(scalar=scalar):
result = MagicMock()
result.scalar_one_or_none.return_value = scalar
db = self._db_with_result(result)
actual = await AccessDao.user_has_warehouse(db, user_id=8, warehouse_id=99)
self.assertIs(expected, actual)
statement = db.execute.await_args.args[0]
self._assert_statement_targets(
statement,
BizUserWarehouse,
BizUserWarehouse.user_id,
{8, 99},
)
async def test_user_has_site_returns_mapping_existence(self) -> None:
for scalar, expected in ((8, True), (None, False)):
with self.subTest(scalar=scalar):
result = MagicMock()
result.scalar_one_or_none.return_value = scalar
db = self._db_with_result(result)
actual = await AccessDao.user_has_site(db, user_id=8, site_id=7)
self.assertIs(expected, actual)
statement = db.execute.await_args.args[0]
self._assert_statement_targets(statement, BizUserSite, BizUserSite.user_id, {7, 8})
async def test_list_user_warehouse_ids_returns_scalar_ids(self) -> None:
result = MagicMock()
result.scalars.return_value.all.return_value = (11, 13)
db = self._db_with_result(result)
warehouse_ids = await AccessDao.list_user_warehouse_ids(db, user_id=8)
self.assertEqual([11, 13], warehouse_ids)
statement = db.execute.await_args.args[0]
self._assert_statement_targets(statement, BizUserWarehouse, BizUserWarehouse.warehouse_id, {8})
async def test_list_user_site_ids_returns_scalar_ids(self) -> None:
result = MagicMock()
result.scalars.return_value.all.return_value = (3, 5)
db = self._db_with_result(result)
site_ids = await AccessDao.list_user_site_ids(db, user_id=8)
self.assertEqual([3, 5], site_ids)
statement = db.execute.await_args.args[0]
self._assert_statement_targets(statement, BizUserSite, BizUserSite.site_id, {8})
async def test_replace_warehouse_users_replaces_complete_mapping(self) -> None:
db = AsyncMock(spec=AsyncSession)
await AccessDao.replace_warehouse_users(db, warehouse_id=99, user_ids=[8, 9])
delete_statement = db.execute.await_args.args[0]
self.assertTrue(delete_statement.table.compare(BizUserWarehouse.__table__))
self.assertTrue(delete_statement.whereclause.left.compare(BizUserWarehouse.warehouse_id.property.columns[0]))
self.assertEqual({99}, set(delete_statement.compile().params.values()))
mappings = db.add_all.call_args.args[0]
self.assertEqual([(8, 99), (9, 99)], [(item.user_id, item.warehouse_id) for item in mappings])
self.assertTrue(all(isinstance(item, BizUserWarehouse) for item in mappings))
self._assert_flush_without_transaction_completion(db)
async def test_replace_warehouse_users_supports_empty_list(self) -> None:
db = AsyncMock(spec=AsyncSession)
await AccessDao.replace_warehouse_users(db, warehouse_id=99, user_ids=[])
delete_statement = db.execute.await_args.args[0]
self.assertTrue(delete_statement.table.compare(BizUserWarehouse.__table__))
self.assertTrue(delete_statement.whereclause.left.compare(BizUserWarehouse.warehouse_id.property.columns[0]))
self.assertEqual({99}, set(delete_statement.compile().params.values()))
db.add_all.assert_called_once_with([])
self._assert_flush_without_transaction_completion(db)
async def test_replace_site_users_replaces_complete_mapping(self) -> None:
db = AsyncMock(spec=AsyncSession)
await AccessDao.replace_site_users(db, site_id=7, user_ids=[8, 9])
delete_statement = db.execute.await_args.args[0]
self.assertTrue(delete_statement.table.compare(BizUserSite.__table__))
self.assertTrue(delete_statement.whereclause.left.compare(BizUserSite.site_id.property.columns[0]))
self.assertEqual({7}, set(delete_statement.compile().params.values()))
mappings = db.add_all.call_args.args[0]
self.assertEqual([(8, 7), (9, 7)], [(item.user_id, item.site_id) for item in mappings])
self.assertTrue(all(isinstance(item, BizUserSite) for item in mappings))
self._assert_flush_without_transaction_completion(db)
async def test_replace_site_users_supports_empty_list(self) -> None:
db = AsyncMock(spec=AsyncSession)
await AccessDao.replace_site_users(db, site_id=7, user_ids=[])
delete_statement = db.execute.await_args.args[0]
self.assertTrue(delete_statement.table.compare(BizUserSite.__table__))
self.assertTrue(delete_statement.whereclause.left.compare(BizUserSite.site_id.property.columns[0]))
self.assertEqual({7}, set(delete_statement.compile().params.values()))
db.add_all.assert_called_once_with([])
self._assert_flush_without_transaction_completion(db)
def _assert_flush_without_transaction_completion(self, db: AsyncMock) -> None:
db.flush.assert_awaited_once_with()
db.commit.assert_not_awaited()
db.rollback.assert_not_awaited()