diff --git a/backend/app/admin/api/v1/sys/__init__.py b/backend/app/admin/api/v1/sys/__init__.py index 8b2b27022..a7628b45b 100644 --- a/backend/app/admin/api/v1/sys/__init__.py +++ b/backend/app/admin/api/v1/sys/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router +from backend.app.admin.api.v1.sys.data_scope import router as data_scope_router from backend.app.admin.api.v1.sys.dept import router as dept_router from backend.app.admin.api.v1.sys.menu import router as menu_router from backend.app.admin.api.v1.sys.plugin import router as plugin_router @@ -18,6 +19,7 @@ router.include_router(role_router, prefix='/roles', tags=['系统角色']) router.include_router(user_router, prefix='/users', tags=['系统用户']) router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则']) +router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围']) router.include_router(token_router, prefix='/tokens', tags=['系统令牌']) router.include_router(upload_router, prefix='/upload', tags=['系统上传']) router.include_router(plugin_router, prefix='/plugin', tags=['系统插件']) diff --git a/backend/app/admin/api/v1/sys/data_rule.py b/backend/app/admin/api/v1/sys/data_rule.py index 374492024..91c42166a 100644 --- a/backend/app/admin/api/v1/sys/data_rule.py +++ b/backend/app/admin/api/v1/sys/data_rule.py @@ -4,7 +4,12 @@ from fastapi import APIRouter, Depends, Path, Query -from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleDetail, UpdateDataRuleParam +from backend.app.admin.schema.data_rule import ( + CreateDataRuleParam, + GetDataRuleColumnDetail, + GetDataRuleDetail, + UpdateDataRuleParam, +) from backend.app.admin.service.data_rule_service import data_rule_service from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base @@ -25,7 +30,7 @@ async def get_data_rule_models() -> ResponseSchemaModel[list[str]]: @router.get('/model/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth]) async def get_data_rule_model_columns( model: Annotated[str, Path(description='模型名称')], -) -> ResponseSchemaModel[list[str]]: +) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]: models = await data_rule_service.get_columns(model=model) return response_base.success(data=models) diff --git a/backend/app/admin/api/v1/sys/data_scope.py b/backend/app/admin/api/v1/sys/data_scope.py new file mode 100644 index 000000000..a97f32c22 --- /dev/null +++ b/backend/app/admin/api/v1/sys/data_scope.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from backend.app.admin.schema.data_scope import ( + CreateDataScopeParam, + GetDataScopeDetail, + GetDataScopeWithRelationDetail, + UpdateDataScopeParam, + UpdateDataScopeRuleParam, +) +from backend.app.admin.service.data_scope_service import data_scope_service +from backend.common.pagination import DependsPagination, PageData, paging_data +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base +from backend.common.security.jwt import DependsJwtAuth +from backend.common.security.permission import RequestPermission +from backend.common.security.rbac import DependsRBAC +from backend.database.db import CurrentSession + +router = APIRouter() + + +@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth]) +async def get_data_scope( + pk: Annotated[int, Path(description='数据范围 ID')], +) -> ResponseSchemaModel[GetDataScopeDetail]: + data = await data_scope_service.get(pk=pk) + return response_base.success(data=data) + + +@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth]) +async def get_data_scope_rules( + pk: Annotated[int, Path(description='数据范围 ID')], +) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]: + data = await data_scope_service.get_rules(pk=pk) + return response_base.success(data=data) + + +@router.get( + '', + summary='分页获取所有数据范围', + dependencies=[ + DependsJwtAuth, + DependsPagination, + ], +) +async def get_pagination_data_scopes( + db: CurrentSession, + name: Annotated[str | None, Query(description='范围名称')] = None, + status: Annotated[int | None, Query(description='状态')] = None, +) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]: + data_scope_select = await data_scope_service.get_select(name=name, status=status) + page_data = await paging_data(db, data_scope_select) + return response_base.success(data=page_data) + + +@router.post( + '', + summary='创建数据范围', + dependencies=[ + Depends(RequestPermission('data:scope:add')), + DependsRBAC, + ], +) +async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel: + await data_scope_service.create(obj=obj) + return response_base.success() + + +@router.put( + '/{pk}', + summary='更新数据范围', + dependencies=[ + Depends(RequestPermission('data:scope:edit')), + DependsRBAC, + ], +) +async def update_data_scope( + pk: Annotated[int, Path(description='数据范围 ID')], obj: UpdateDataScopeParam +) -> ResponseModel: + count = await data_scope_service.update(pk=pk, obj=obj) + if count > 0: + return response_base.success() + return response_base.fail() + + +@router.put( + '/{pk}/rules', + summary='更新数据范围规则', + dependencies=[ + Depends(RequestPermission('data:scope:rule:edit')), + DependsRBAC, + ], +) +async def update_data_scope_rules( + pk: Annotated[int, Path(description='数据范围 ID')], rule_ids: UpdateDataScopeRuleParam +): + count = await data_scope_service.update_data_scope_rule(pk=pk, rule_ids=rule_ids) + if count > 0: + return response_base.success() + return response_base.fail() + + +@router.delete( + '', + summary='批量删除数据范围', + dependencies=[ + Depends(RequestPermission('data:scope:del')), + DependsRBAC, + ], +) +async def delete_data_scope(pk: Annotated[list[int], Query(description='数据范围 ID 列表')]) -> ResponseModel: + count = await data_scope_service.delete(pk=pk) + if count > 0: + return response_base.success() + return response_base.fail() diff --git a/backend/app/admin/api/v1/sys/dept.py b/backend/app/admin/api/v1/sys/dept.py index 0c9a3b969..0bce3a44c 100644 --- a/backend/app/admin/api/v1/sys/dept.py +++ b/backend/app/admin/api/v1/sys/dept.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from typing import Annotated, Any -from fastapi import APIRouter, Depends, Path, Query +from fastapi import APIRouter, Depends, Path, Query, Request from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, UpdateDeptParam from backend.app.admin.service.dept_service import dept_service @@ -22,12 +22,13 @@ async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> Respons @router.get('', summary='获取所有部门展示树', dependencies=[DependsJwtAuth]) async def get_all_depts( + request: Request, name: Annotated[str | None, Query(description='部门名称')] = None, leader: Annotated[str | None, Query(description='部门负责人')] = None, phone: Annotated[str | None, Query(description='联系电话')] = None, status: Annotated[int | None, Query(description='状态')] = None, ) -> ResponseSchemaModel[list[dict[str, Any]]]: - dept = await dept_service.get_dept_tree(name=name, leader=leader, phone=phone, status=status) + dept = await dept_service.get_dept_tree(request=request, name=name, leader=leader, phone=phone, status=status) return response_base.success(data=dept) diff --git a/backend/app/admin/api/v1/sys/role.py b/backend/app/admin/api/v1/sys/role.py index 1dbf9e53f..1ac94f830 100644 --- a/backend/app/admin/api/v1/sys/role.py +++ b/backend/app/admin/api/v1/sys/role.py @@ -10,10 +10,8 @@ GetRoleWithRelationDetail, UpdateRoleMenuParam, UpdateRoleParam, - UpdateRoleRuleParam, + UpdateRoleScopeParam, ) -from backend.app.admin.service.data_rule_service import data_rule_service -from backend.app.admin.service.menu_service import menu_service from backend.app.admin.service.role_service import role_service from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base @@ -35,7 +33,7 @@ async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]: async def get_user_all_roles( pk: Annotated[int, Path(description='用户 ID')], ) -> ResponseSchemaModel[list[GetRoleDetail]]: - data = await role_service.get_by_user(pk=pk) + data = await role_service.get_users(pk=pk) return response_base.success(data=data) @@ -43,13 +41,13 @@ async def get_user_all_roles( async def get_role_all_menus( pk: Annotated[int, Path(description='角色 ID')], ) -> ResponseSchemaModel[list[dict[str, Any]]]: - menu = await menu_service.get_role_menu_tree(pk=pk) + menu = await role_service.get_menu_tree(pk=pk) return response_base.success(data=menu) -@router.get('/{pk}/rules', summary='获取角色所有数据规则', dependencies=[DependsJwtAuth]) -async def get_role_all_rules(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]: - rule = await data_rule_service.get_role_rules(pk=pk) +@router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth]) +async def get_role_all_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]: + rule = await role_service.get_scopes(pk=pk) return response_base.success(data=rule) @@ -125,17 +123,17 @@ async def update_role_menus( @router.put( - '/{pk}/rule', - summary='更新角色数据规则', + '/{pk}/scope', + summary='更新角色数据范围', dependencies=[ - Depends(RequestPermission('sys:role:rule:edit')), + Depends(RequestPermission('sys:role:scope:edit')), DependsRBAC, ], ) -async def update_role_rules( - pk: Annotated[int, Path(description='角色 ID')], rule_ids: UpdateRoleRuleParam +async def update_role_scopes( + pk: Annotated[int, Path(description='角色 ID')], scope_ids: UpdateRoleScopeParam ) -> ResponseModel: - count = await role_service.update_role_rule(pk=pk, rule_ids=rule_ids) + count = await role_service.update_role_scope(pk=pk, scope_ids=scope_ids) if count > 0: return response_base.success() return response_base.fail() diff --git a/backend/app/admin/api/v1/sys/upload.py b/backend/app/admin/api/v1/sys/upload.py index 44f6f6106..f81d2450d 100644 --- a/backend/app/admin/api/v1/sys/upload.py +++ b/backend/app/admin/api/v1/sys/upload.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- - from typing import Annotated from fastapi import APIRouter, File, UploadFile diff --git a/backend/app/admin/crud/crud_data_rule.py b/backend/app/admin/crud/crud_data_rule.py index 437b25f25..5ea4b4da2 100644 --- a/backend/app/admin/crud/crud_data_rule.py +++ b/backend/app/admin/crud/crud_data_rule.py @@ -31,7 +31,7 @@ async def get_list(self, name: str | None) -> Select: :param name: 规则名称 :return: """ - stmt = select(self.model).options(noload(self.model.roles)).order_by(desc(self.model.created_time)) + stmt = select(self.model).options(noload(self.model.scope)).order_by(desc(self.model.created_time)) filters = [] if name is not None: diff --git a/backend/app/admin/crud/crud_data_scope.py b/backend/app/admin/crud/crud_data_scope.py new file mode 100644 index 000000000..116bd56e6 --- /dev/null +++ b/backend/app/admin/crud/crud_data_scope.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from sqlalchemy import Select, and_, desc, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload, selectinload +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.admin.model import DataRule, DataScope +from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam + + +class CRUDDataScope(CRUDPlus[DataScope]): + """数据范围数据库操作类""" + + async def get(self, db: AsyncSession, pk: int) -> DataScope | None: + """ + 获取数据范围详情 + + :param db: 数据库会话 + :param pk: 范围 ID + :return: + """ + return await self.select_model(db, pk) + + async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None: + """ + 通过名称获取数据范围 + + :param db: 数据库会话 + :param name: 范围名称 + :return: + """ + return await self.select_model_by_column(db, name=name) + + async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope: + """ + 获取数据范围关联数据 + + :param db: 数据库会话 + :param pk: 范围 ID + :return: + """ + stmt = select(self.model).options(selectinload(self.model.rules)).where(self.model.id == pk) + data_scope = await db.execute(stmt) + return data_scope.scalars().first() + + async def get_list(self, name: str | None, status: int | None) -> Select: + """ + 获取数据范围列表 + + :param name: 范围名称 + :param status: 范围状态 + :return: + """ + stmt = ( + select(self.model) + .options(noload(self.model.rules), noload(self.model.roles)) + .order_by(desc(self.model.created_time)) + ) + + filters = [] + if name is not None: + filters.append(self.model.name.like(f'%{name}%')) + if status is not None: + filters.append(self.model.status == status) + + if filters: + stmt = stmt.where(and_(*filters)) + + return stmt + + async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None: + """ + 创建数据范围 + + :param db: 数据库会话 + :param obj: 创建数据范围参数 + :return: + """ + await self.create_model(db, obj) + + async def update(self, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int: + """ + 更新数据范围 + + :param db: 数据库会话 + :param pk: 范围 ID + :param obj: 更新数据范围参数 + :return: + """ + return await self.update_model(db, pk, obj) + + async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int: + """ + 更新数据范围规则 + + :param db: 数据库会话 + :param pk: 范围 ID + :param rule_ids: 数据规则 ID 列表 + :return: + """ + current_data_scope = await self.get_with_relation(db, pk) + stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules)) + rules = await db.execute(stmt) + current_data_scope.rules = rules.scalars().all() + return len(current_data_scope.rules) + + async def delete(self, db: AsyncSession, pk: list[int]) -> int: + """ + 删除数据范围 + + :param db: 数据库会话 + :param pk: 范围 ID 列表 + :return: + """ + return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) + + +data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope) diff --git a/backend/app/admin/crud/crud_dept.py b/backend/app/admin/crud/crud_dept.py index 4536a270f..5ca334c0c 100644 --- a/backend/app/admin/crud/crud_dept.py +++ b/backend/app/admin/crud/crud_dept.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from typing import Sequence +from fastapi import Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -9,6 +10,7 @@ from backend.app.admin.model import Dept from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam +from backend.common.security.permission import filter_data_permission class CRUDDept(CRUDPlus[Dept]): @@ -35,11 +37,18 @@ async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None: return await self.select_model_by_column(db, name=name, del_flag=0) async def get_all( - self, db: AsyncSession, name: str | None, leader: str | None, phone: str | None, status: int | None + self, + request: Request, + db: AsyncSession, + name: str | None, + leader: str | None, + phone: str | None, + status: int | None, ) -> Sequence[Dept]: """ 获取所有部门 + :param request: FastAPI 请求对象 :param db: 数据库会话 :param name: 部门名称 :param leader: 负责人 @@ -56,7 +65,7 @@ async def get_all( filters.update(phone__startswith=phone) if status is not None: filters.update(status=status) - return await self.select_models_order(db, sort_columns='sort', **filters) + return await self.select_models_order(db, 'sort', None, await filter_data_permission(db, request), **filters) async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None: """ diff --git a/backend/app/admin/crud/crud_role.py b/backend/app/admin/crud/crud_role.py index 5d60895aa..1b1df8411 100644 --- a/backend/app/admin/crud/crud_role.py +++ b/backend/app/admin/crud/crud_role.py @@ -7,12 +7,12 @@ from sqlalchemy.orm import noload, selectinload from sqlalchemy_crud_plus import CRUDPlus -from backend.app.admin.model import DataRule, Menu, Role, User +from backend.app.admin.model import DataScope, Menu, Role, User from backend.app.admin.schema.role import ( CreateRoleParam, UpdateRoleMenuParam, UpdateRoleParam, - UpdateRoleRuleParam, + UpdateRoleScopeParam, ) @@ -39,7 +39,7 @@ async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None """ stmt = ( select(self.model) - .options(selectinload(self.model.menus), selectinload(self.model.rules)) + .options(selectinload(self.model.menus), selectinload(self.model.scopes)) .where(self.model.id == role_id) ) role = await db.execute(stmt) @@ -54,7 +54,7 @@ async def get_all(self, db: AsyncSession) -> Sequence[Role]: """ return await self.select_models(db) - async def get_by_user(self, db: AsyncSession, user_id: int) -> Sequence[Role]: + async def get_users(self, db: AsyncSession, user_id: int) -> Sequence[Role]: """ 获取用户角色列表 @@ -76,7 +76,7 @@ async def get_list(self, name: str | None, status: int | None) -> Select: """ stmt = ( select(self.model) - .options(noload(self.model.users), noload(self.model.menus), noload(self.model.rules)) + .options(noload(self.model.users), noload(self.model.menus), noload(self.model.scopes)) .order_by(desc(self.model.created_time)) ) @@ -137,20 +137,20 @@ async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRol current_role.menus = menus.scalars().all() return len(current_role.menus) - async def update_rules(self, db: AsyncSession, role_id: int, rule_ids: UpdateRoleRuleParam) -> int: + async def update_scopes(self, db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int: """ - 更新角色数据规则 + 更新角色数据范围 :param db: 数据库会话 :param role_id: 角色 ID - :param rule_ids: 权限规则 ID 列表 + :param scope_ids: 权限范围 ID 列表 :return: """ current_role = await self.get_with_relation(db, role_id) - stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules)) - rules = await db.execute(stmt) - current_role.rules = rules.scalars().all() - return len(current_role.rules) + stmt = select(DataScope).where(DataScope.id.in_(scope_ids.scopes)) + scopes = await db.execute(stmt) + current_role.scopes = scopes.scalars().all() + return len(current_role.scopes) async def delete(self, db: AsyncSession, role_id: list[int]) -> int: """ diff --git a/backend/app/admin/crud/crud_user.py b/backend/app/admin/crud/crud_user.py index 3f5de5d9c..6bbf82ac9 100644 --- a/backend/app/admin/crud/crud_user.py +++ b/backend/app/admin/crud/crud_user.py @@ -311,7 +311,7 @@ async def get_with_relation( """ stmt = select(self.model).options( selectinload(self.model.dept), - selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.rules)), + selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes)), ) filters = [] diff --git a/backend/app/admin/model/__init__.py b/backend/app/admin/model/__init__.py index 94e32a25d..3f55eae8d 100644 --- a/backend/app/admin/model/__init__.py +++ b/backend/app/admin/model/__init__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- from backend.app.admin.model.data_rule import DataRule +from backend.app.admin.model.data_scope import DataScope from backend.app.admin.model.dept import Dept from backend.app.admin.model.login_log import LoginLog from backend.app.admin.model.menu import Menu diff --git a/backend/app/admin/model/data_rule.py b/backend/app/admin/model/data_rule.py index 3c790e765..4c8fde1c7 100644 --- a/backend/app/admin/model/data_rule.py +++ b/backend/app/admin/model/data_rule.py @@ -4,14 +4,13 @@ from typing import TYPE_CHECKING -from sqlalchemy import String +from sqlalchemy import ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship -from backend.app.admin.model.m2m import sys_role_data_rule from backend.common.model import Base, id_key if TYPE_CHECKING: - from backend.app.admin.model import Role + from backend.app.admin.model import DataScope class DataRule(Base): @@ -20,14 +19,17 @@ class DataRule(Base): __tablename__ = 'sys_data_rule' id: Mapped[id_key] = mapped_column(init=False) - name: Mapped[str] = mapped_column(String(255), unique=True, comment='规则名称') - model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型类') - column: Mapped[str] = mapped_column(String(20), comment='数据库字段') + name: Mapped[str] = mapped_column(String(500), unique=True, comment='名称') + model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名') + column: Mapped[str] = mapped_column(String(20), comment='模型字段名') operator: Mapped[int] = mapped_column(comment='运算符(0:and、1:or)') expression: Mapped[int] = mapped_column( comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_in)' ) value: Mapped[str] = mapped_column(String(255), comment='规则值') - # 角色规则多对多 - roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='rules') + # 数据范围规则一对多 + scope_id: Mapped[int | None] = mapped_column( + ForeignKey('sys_data_scope.id', ondelete='SET NULL'), default=None, comment='数据范围关联 ID' + ) + scope: Mapped[DataScope] = relationship(init=False, back_populates='rules') diff --git a/backend/app/admin/model/data_scope.py b/backend/app/admin/model/data_scope.py new file mode 100644 index 000000000..1289dfe7d --- /dev/null +++ b/backend/app/admin/model/data_scope.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.admin.model.m2m import sys_role_data_scope +from backend.common.model import Base, id_key + +if TYPE_CHECKING: + from backend.app.admin.model import DataRule, Role + + +class DataScope(Base): + """数据范围表""" + + __tablename__ = 'sys_data_scope' + + id: Mapped[id_key] = mapped_column(init=False) + name: Mapped[str] = mapped_column(String(50), unique=True, comment='名称') + status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)') + + # 数据范围规则一对多 + rules: Mapped[list[DataRule]] = relationship(init=False, back_populates='scope') + + # 角色数据范围多对多 + roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes') diff --git a/backend/app/admin/model/m2m.py b/backend/app/admin/model/m2m.py index ab68ebfa9..33f707006 100644 --- a/backend/app/admin/model/m2m.py +++ b/backend/app/admin/model/m2m.py @@ -20,16 +20,16 @@ Column('menu_id', Integer, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'), ) -sys_role_data_rule = Table( - 'sys_role_data_rule', +sys_role_data_scope = Table( + 'sys_role_data_scope', MappedBase.metadata, - Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), - Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'), + Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'), + Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'), Column( - 'data_rule_id', + 'data_scope_id', Integer, - ForeignKey('sys_data_rule.id', ondelete='CASCADE'), + ForeignKey('sys_data_scope.id', ondelete='CASCADE'), primary_key=True, - comment='数据规则ID', + comment='数据范围 ID', ), ) diff --git a/backend/app/admin/model/role.py b/backend/app/admin/model/role.py index 2fd0498f2..3818b9056 100644 --- a/backend/app/admin/model/role.py +++ b/backend/app/admin/model/role.py @@ -9,11 +9,11 @@ from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.orm import Mapped, mapped_column, relationship -from backend.app.admin.model.m2m import sys_role_data_rule, sys_role_menu, sys_user_role +from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role from backend.common.model import Base, id_key if TYPE_CHECKING: - from backend.app.admin.model import DataRule, Menu, User + from backend.app.admin.model import DataScope, Menu, User class Role(Base): @@ -34,5 +34,5 @@ class Role(Base): # 角色菜单多对多 menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles') - # 角色数据规则多对多 - rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='roles') + # 角色数据范围多对多 + scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles') diff --git a/backend/app/admin/schema/data_rule.py b/backend/app/admin/schema/data_rule.py index 17bfe4d16..ff0456025 100644 --- a/backend/app/admin/schema/data_rule.py +++ b/backend/app/admin/schema/data_rule.py @@ -14,7 +14,7 @@ class DataRuleSchemaBase(SchemaBase): name: str = Field(description='规则名称') model: str = Field(description='模型名称') column: str = Field(description='字段名称') - operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.OR, description='操作符(AND/OR)') + operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.AND, description='操作符(AND/OR)') expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型') value: str = Field(description='规则值') @@ -36,6 +36,9 @@ class GetDataRuleDetail(DataRuleSchemaBase): created_time: datetime = Field(description='创建时间') updated_time: datetime | None = Field(None, description='更新时间') - def __hash__(self) -> int: - """计算哈希值""" - return hash(self.name) + +class GetDataRuleColumnDetail(SchemaBase): + """数据规则可用模型字段详情""" + + key: str = Field(description='字段名') + comment: str = Field(description='字段评论') diff --git a/backend/app/admin/schema/data_scope.py b/backend/app/admin/schema/data_scope.py new file mode 100644 index 000000000..abe8b96b6 --- /dev/null +++ b/backend/app/admin/schema/data_scope.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime + +from pydantic import ConfigDict, Field + +from backend.app.admin.schema.data_rule import GetDataRuleDetail +from backend.common.enums import StatusType +from backend.common.schema import SchemaBase + + +class DataScopeBase(SchemaBase): + """数据范围基础模型""" + + name: str = Field(description='名称') + status: StatusType = Field(StatusType.enable, description='状态') + + +class CreateDataScopeParam(DataScopeBase): + """创建数据范围参数""" + + +class UpdateDataScopeParam(DataScopeBase): + """更新数据范围参数""" + + +class UpdateDataScopeRuleParam(SchemaBase): + """更新数据范围规则参数""" + + rules: list[int] = Field(description='数据规则 ID 列表') + + +class GetDataScopeDetail(DataScopeBase): + """数据范围详情""" + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(description='数据范围 ID') + created_time: datetime = Field(description='创建时间') + updated_time: datetime | None = Field(None, description='更新时间') + + +class GetDataScopeWithRelationDetail(GetDataScopeDetail): + """数据范围关联详情""" + + rules: list[GetDataRuleDetail] = Field([], description='数据规则列表') diff --git a/backend/app/admin/schema/role.py b/backend/app/admin/schema/role.py index 70f95b810..dbdd9b630 100644 --- a/backend/app/admin/schema/role.py +++ b/backend/app/admin/schema/role.py @@ -4,7 +4,7 @@ from pydantic import ConfigDict, Field -from backend.app.admin.schema.data_rule import GetDataRuleDetail +from backend.app.admin.schema.data_scope import GetDataScopeDetail from backend.app.admin.schema.menu import GetMenuDetail from backend.common.enums import StatusType from backend.common.schema import SchemaBase @@ -32,10 +32,10 @@ class UpdateRoleMenuParam(SchemaBase): menus: list[int] = Field(description='菜单 ID 列表') -class UpdateRoleRuleParam(SchemaBase): - """更新角色规则参数""" +class UpdateRoleScopeParam(SchemaBase): + """更新角色数据范围参数""" - rules: list[int] = Field(description='数据规则 ID 列表') + scopes: list[int] = Field(description='数据范围 ID 列表') class GetRoleDetail(RoleSchemaBase): @@ -52,4 +52,4 @@ class GetRoleWithRelationDetail(GetRoleDetail): """角色关联详情""" menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表') - rules: list[GetDataRuleDetail | None] = Field([], description='数据规则详情列表') + scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表') diff --git a/backend/app/admin/service/data_rule_service.py b/backend/app/admin/service/data_rule_service.py index 487dd2d79..ec5eb4adb 100644 --- a/backend/app/admin/service/data_rule_service.py +++ b/backend/app/admin/service/data_rule_service.py @@ -5,13 +5,11 @@ from sqlalchemy import Select from backend.app.admin.crud.crud_data_rule import data_rule_dao -from backend.app.admin.crud.crud_role import role_dao from backend.app.admin.model import DataRule -from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRuleParam +from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleColumnDetail, UpdateDataRuleParam from backend.common.exception import errors from backend.core.conf import settings from backend.database.db import async_db_session -from backend.database.redis import redis_client from backend.utils.import_parse import dynamic_import_data_model @@ -32,28 +30,13 @@ async def get(*, pk: int) -> DataRule: raise errors.NotFoundError(msg='数据规则不存在') return data_rule - @staticmethod - async def get_role_rules(*, pk: int) -> list[int]: - """ - 获取角色的数据规则列表 - - :param pk: 角色 ID - :return: - """ - async with async_db_session() as db: - role = await role_dao.get_with_relation(db, pk) - if not role: - raise errors.NotFoundError(msg='角色不存在') - rule_ids = [rule.id for rule in role.rules] - return rule_ids - @staticmethod async def get_models() -> list[str]: """获取所有数据规则可用模型""" return list(settings.DATA_PERMISSION_MODELS.keys()) @staticmethod - async def get_columns(model: str) -> list[str]: + async def get_columns(model: str) -> list[GetDataRuleColumnDetail]: """ 获取数据规则可用模型的字段列表 @@ -63,8 +46,11 @@ async def get_columns(model: str) -> list[str]: if model not in settings.DATA_PERMISSION_MODELS: raise errors.NotFoundError(msg='数据规则可用模型不存在') model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model]) + model_columns = [ - key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE + GetDataRuleColumnDetail(key=column.key, comment=column.comment) + for column in model_ins.__table__.columns + if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE ] return model_columns @@ -112,10 +98,10 @@ async def update(*, pk: int, obj: UpdateDataRuleParam) -> int: data_rule = await data_rule_dao.get(db, pk) if not data_rule: raise errors.NotFoundError(msg='数据规则不存在') + if data_rule.name != obj.name: + if await data_rule_dao.get_by_name(db, obj.name): + raise errors.ForbiddenError(msg='数据规则已存在') count = await data_rule_dao.update(db, pk, obj) - for role in await data_rule.awaitable_attrs.roles: - for user in await role.awaitable_attrs.users: - await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count @staticmethod @@ -128,12 +114,6 @@ async def delete(*, pk: list[int]) -> int: """ async with async_db_session.begin() as db: count = await data_rule_dao.delete(db, pk) - for _pk in pk: - data_rule = await data_rule_dao.get(db, _pk) - if data_rule: - for role in await data_rule.awaitable_attrs.roles: - for user in await role.awaitable_attrs.users: - await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count diff --git a/backend/app/admin/service/data_scope_service.py b/backend/app/admin/service/data_scope_service.py new file mode 100644 index 000000000..86464de91 --- /dev/null +++ b/backend/app/admin/service/data_scope_service.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from sqlalchemy import Select + +from backend.app.admin.crud.crud_data_scope import data_scope_dao +from backend.app.admin.model import DataScope +from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam +from backend.common.exception import errors +from backend.core.conf import settings +from backend.database.db import async_db_session +from backend.database.redis import redis_client + + +class DataScopeService: + """数据范围服务类""" + + @staticmethod + async def get(*, pk: int) -> DataScope: + """ + 获取数据范围详情 + + :param pk: 范围 ID + :return: + """ + async with async_db_session() as db: + data_scope = await data_scope_dao.get(db, pk) + if not data_scope: + raise errors.NotFoundError(msg='数据范围不存在') + return data_scope + + @staticmethod + async def get_rules(*, pk: int) -> DataScope: + """ + 获取数据范围规则 + + :param pk: 范围 ID + :return: + """ + async with async_db_session() as db: + data_scope = await data_scope_dao.get_with_relation(db, pk) + if not data_scope: + raise errors.NotFoundError(msg='数据范围不存在') + return data_scope + + @staticmethod + async def get_select(*, name: str | None, status: int | None) -> Select: + """ + 获取数据范围列表查询条件 + + :param name: 范围名称 + :param status: 范围状态 + :return: + """ + return await data_scope_dao.get_list(name, status) + + @staticmethod + async def create(*, obj: CreateDataScopeParam) -> None: + """ + 创建数据范围 + + :param obj: 数据范围参数 + :return: + """ + async with async_db_session.begin() as db: + data_scope = await data_scope_dao.get_by_name(db, obj.name) + if data_scope: + raise errors.ForbiddenError(msg='数据范围已存在') + await data_scope_dao.create(db, obj) + + @staticmethod + async def update(*, pk: int, obj: UpdateDataScopeParam) -> int: + """ + 更新数据范围 + + :param pk: 范围 ID + :param obj: 数据范围更新参数 + :return: + """ + async with async_db_session.begin() as db: + data_scope = await data_scope_dao.get(db, pk) + if not data_scope: + raise errors.NotFoundError(msg='数据范围不存在') + if data_scope.name != obj.name: + if await data_scope_dao.get_by_name(db, obj.name): + raise errors.ForbiddenError(msg='数据范围已存在') + count = await data_scope_dao.update(db, pk, obj) + for role in await data_scope.awaitable_attrs.roles: + for user in await role.awaitable_attrs.users: + await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') + return count + + @staticmethod + async def update_data_scope_rule(*, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int: + """ + 更新数据范围规则 + + :param pk: 范围 ID + :param rule_ids: 规则 ID 列表 + :return: + """ + async with async_db_session.begin() as db: + count = await data_scope_dao.update_rules(db, pk, rule_ids) + return count + + @staticmethod + async def delete(*, pk: list[int]) -> int: + """ + 删除数据范围 + + :param pk: 范围 ID 列表 + :return: + """ + async with async_db_session.begin() as db: + count = await data_scope_dao.delete(db, pk) + for _pk in pk: + data_rule = await data_scope_dao.get(db, _pk) + if data_rule: + for role in await data_rule.awaitable_attrs.roles: + for user in await role.awaitable_attrs.users: + await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') + return count + + +data_scope_service: DataScopeService = DataScopeService() diff --git a/backend/app/admin/service/dept_service.py b/backend/app/admin/service/dept_service.py index 1006b575b..996ac1ea7 100644 --- a/backend/app/admin/service/dept_service.py +++ b/backend/app/admin/service/dept_service.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- from typing import Any +from fastapi import Request + from backend.app.admin.crud.crud_dept import dept_dao from backend.app.admin.model import Dept from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam @@ -31,11 +33,12 @@ async def get(*, pk: int) -> Dept: @staticmethod async def get_dept_tree( - *, name: str | None, leader: str | None, phone: str | None, status: int | None + *, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None ) -> list[dict[str, Any]]: """ 获取部门树形结构 + :param request: FastAPI 请求对象 :param name: 部门名称 :param leader: 部门负责人 :param phone: 联系电话 @@ -43,7 +46,7 @@ async def get_dept_tree( :return: """ async with async_db_session() as db: - dept_select = await dept_dao.get_all(db=db, name=name, leader=leader, phone=phone, status=status) + dept_select = await dept_dao.get_all(request, db, name, leader, phone, status) tree_data = get_tree_data(dept_select) return tree_data diff --git a/backend/app/admin/service/menu_service.py b/backend/app/admin/service/menu_service.py index 9123bd0b8..67a4b0fea 100644 --- a/backend/app/admin/service/menu_service.py +++ b/backend/app/admin/service/menu_service.py @@ -5,7 +5,6 @@ from fastapi import Request from backend.app.admin.crud.crud_menu import menu_dao -from backend.app.admin.crud.crud_role import role_dao from backend.app.admin.model import Menu from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam from backend.common.exception import errors @@ -46,23 +45,6 @@ async def get_menu_tree(*, title: str | None, status: int | None) -> list[dict[s menu_tree = get_tree_data(menu_select) return menu_tree - @staticmethod - async def get_role_menu_tree(*, pk: int) -> list[dict[str, Any]]: - """ - 获取角色的菜单树形结构 - - :param pk: 角色 ID - :return: - """ - async with async_db_session() as db: - role = await role_dao.get_with_relation(db, pk) - if not role: - raise errors.NotFoundError(msg='角色不存在') - menu_ids = [menu.id for menu in role.menus] - menu_select = await menu_dao.get_role_menus(db, False, menu_ids) - menu_tree = get_tree_data(menu_select) - return menu_tree - @staticmethod async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]: """ diff --git a/backend/app/admin/service/role_service.py b/backend/app/admin/service/role_service.py index 4d9ffa93f..32648e5d5 100644 --- a/backend/app/admin/service/role_service.py +++ b/backend/app/admin/service/role_service.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from typing import Sequence +from typing import Any, Sequence from sqlalchemy import Select -from backend.app.admin.crud.crud_data_rule import data_rule_dao +from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.app.admin.crud.crud_menu import menu_dao from backend.app.admin.crud.crud_role import role_dao from backend.app.admin.model import Role @@ -12,12 +12,13 @@ CreateRoleParam, UpdateRoleMenuParam, UpdateRoleParam, - UpdateRoleRuleParam, + UpdateRoleScopeParam, ) from backend.common.exception import errors from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client +from backend.utils.build_tree import get_tree_data class RoleService: @@ -45,7 +46,7 @@ async def get_all() -> Sequence[Role]: return roles @staticmethod - async def get_by_user(*, pk: int) -> Sequence[Role]: + async def get_users(*, pk: int) -> Sequence[Role]: """ 获取用户的角色列表 @@ -53,7 +54,7 @@ async def get_by_user(*, pk: int) -> Sequence[Role]: :return: """ async with async_db_session() as db: - roles = await role_dao.get_by_user(db, user_id=pk) + roles = await role_dao.get_users(db, user_id=pk) return roles @staticmethod @@ -67,6 +68,38 @@ async def get_select(*, name: str | None, status: int | None) -> Select: """ return await role_dao.get_list(name=name, status=status) + @staticmethod + async def get_menu_tree(*, pk: int) -> list[dict[str, Any]]: + """ + 获取角色的菜单树形结构 + + :param pk: 角色 ID + :return: + """ + async with async_db_session() as db: + role = await role_dao.get_with_relation(db, pk) + if not role: + raise errors.NotFoundError(msg='角色不存在') + menu_ids = [menu.id for menu in role.menus] + menu_select = await menu_dao.get_role_menus(db, False, menu_ids) + menu_tree = get_tree_data(menu_select) + return menu_tree + + @staticmethod + async def get_scopes(*, pk: int) -> list[int]: + """ + 获取角色数据范围列表 + + :param pk: + :return: + """ + async with async_db_session() as db: + role = await role_dao.get_with_relation(db, pk) + if not role: + raise errors.NotFoundError(msg='角色不存在') + scope_ids = [scope.id for scope in role.scopes] + return scope_ids + @staticmethod async def create(*, obj: CreateRoleParam) -> None: """ @@ -126,23 +159,23 @@ async def update_role_menu(*, pk: int, menu_ids: UpdateRoleMenuParam) -> int: return count @staticmethod - async def update_role_rule(*, pk: int, rule_ids: UpdateRoleRuleParam) -> int: + async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int: """ - 更新角色数据规则 + 更新角色数据范围 :param pk: 角色 ID - :param rule_ids: 权限规则 ID 列表 + :param scope_ids: 权限规则 ID 列表 :return: """ async with async_db_session.begin() as db: role = await role_dao.get(db, pk) if not role: raise errors.NotFoundError(msg='角色不存在') - for rule_id in rule_ids.rules: - rule = await data_rule_dao.get(db, rule_id) - if not rule: - raise errors.NotFoundError(msg='数据规则不存在') - count = await role_dao.update_rules(db, pk, rule_ids) + for scope_id in scope_ids.scopes: + scope = await data_scope_dao.get(db, scope_id) + if not scope: + raise errors.NotFoundError(msg='数据范围不存在') + count = await role_dao.update_scopes(db, pk, scope_ids) for user in await role.awaitable_attrs.users: await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') return count diff --git a/backend/common/response/response_schema.py b/backend/common/response/response_schema.py index 8be38e9df..56a9f2c35 100644 --- a/backend/common/response/response_schema.py +++ b/backend/common/response/response_schema.py @@ -40,7 +40,7 @@ def test() -> ResponseModel: class ResponseSchemaModel(ResponseModel, Generic[SchemaT]): """ - 包含返回数据 schema 的通用型统一返回模型,仅适用于非分页接口 + 包含返回数据 schema 的通用型统一返回模型 示例:: diff --git a/backend/common/security/permission.py b/backend/common/security/permission.py index ec233255a..48c25946d 100644 --- a/backend/common/security/permission.py +++ b/backend/common/security/permission.py @@ -4,7 +4,9 @@ from fastapi import Request from sqlalchemy import ColumnElement, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType from backend.common.exception import errors from backend.common.exception.errors import ServerError @@ -12,7 +14,7 @@ from backend.utils.import_parse import dynamic_import_data_model if TYPE_CHECKING: - from backend.app.admin.schema.data_rule import GetDataRuleDetail + from backend.app.admin.model import DataRule class RequestPermission: @@ -47,33 +49,48 @@ async def __call__(self, request: Request) -> None: request.state.permission = self.value -def filter_data_permission(request: Request) -> ColumnElement[bool]: +async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnElement[bool]: """ 过滤数据权限,控制用户可见数据范围 使用场景: - - 用户登录前台后,控制其能看到哪些数据 - - 根据用户角色和规则过滤数据访问权限 + - 控制用户能看到哪些数据 + :param db: 数据库会话 :param request: FastAPI 请求对象 :return: """ - # 获取用户角色和规则 - data_rules = [] + # 获取用户角色和数据范围 + data_scopes = [] for role in request.user.roles: - data_rules.extend(role.rules) - user_data_rules: list[GetDataRuleDetail] = list(dict.fromkeys(data_rules)) + for scope in role.scopes: + if scope.status: + data_scopes.append(scope) # 超级管理员和无规则用户不做过滤 - if request.user.is_superuser or not user_data_rules: + if request.user.is_superuser or not data_scopes: return or_(1 == 1) + # 获取数据范围规则 + data_rule_list: list[DataRule] = [] + for data_scope in data_scopes: + data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope.id) + data_rule_list.extend(data_scope_with_relation.rules) + + # 去重 + seen_data_rule_ids = set() + new_data_rule_list = [] + for rule in data_rule_list: + if rule.id not in seen_data_rule_ids: + seen_data_rule_ids.add(rule.id) + new_data_rule_list.append(rule) + where_and_list = [] where_or_list = [] - for rule in user_data_rules: + for data_rule in new_data_rule_list: # 验证规则模型 - rule_model = rule.model + rule_model = data_rule.model if rule_model not in settings.DATA_PERMISSION_MODELS: raise errors.NotFoundError(msg='数据规则模型不存在') model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model]) @@ -82,39 +99,41 @@ def filter_data_permission(request: Request) -> ColumnElement[bool]: model_columns = [ key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE ] - column = rule.column + column = data_rule.column if column not in model_columns: raise errors.NotFoundError(msg='数据规则模型列不存在') # 构建过滤条件 column_obj = getattr(model_ins, column) - rule_expression = rule.expression + rule_expression = data_rule.expression condition = None - if rule_expression == RoleDataRuleExpressionType.eq: - condition = column_obj == rule.value - elif rule_expression == RoleDataRuleExpressionType.ne: - condition = column_obj != rule.value - elif rule_expression == RoleDataRuleExpressionType.gt: - condition = column_obj > rule.value - elif rule_expression == RoleDataRuleExpressionType.ge: - condition = column_obj >= rule.value - elif rule_expression == RoleDataRuleExpressionType.lt: - condition = column_obj < rule.value - elif rule_expression == RoleDataRuleExpressionType.le: - condition = column_obj <= rule.value - elif rule_expression == RoleDataRuleExpressionType.in_: - values = rule.value.split(',') if isinstance(rule.value, str) else rule.value - condition = column_obj.in_(values) - elif rule.expression == RoleDataRuleExpressionType.not_in: - values = rule.value.split(',') if isinstance(rule.value, str) else rule.value - condition = ~column_obj.in_(values) + match rule_expression: + case RoleDataRuleExpressionType.eq: + condition = column_obj == data_rule.value + case RoleDataRuleExpressionType.ne: + condition = column_obj != data_rule.value + case RoleDataRuleExpressionType.gt: + condition = column_obj > data_rule.value + case RoleDataRuleExpressionType.ge: + condition = column_obj >= data_rule.value + case RoleDataRuleExpressionType.lt: + condition = column_obj < data_rule.value + case RoleDataRuleExpressionType.le: + condition = column_obj <= data_rule.value + case RoleDataRuleExpressionType.in_: + values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value + condition = column_obj.in_(values) + case RoleDataRuleExpressionType.not_in: + values = data_rule.value.split(',') if isinstance(data_rule.value, str) else data_rule.value + condition = column_obj.not_in(values) # 根据运算符添加到对应列表 if condition is not None: - if rule.operator == RoleDataRuleOperatorType.AND: - where_and_list.append(condition) - elif rule.operator == RoleDataRuleOperatorType.OR: - where_or_list.append(condition) + match data_rule.operator: + case RoleDataRuleOperatorType.AND: + where_and_list.append(condition) + case RoleDataRuleOperatorType.OR: + where_or_list.append(condition) # 组合所有条件 where_list = [] diff --git a/backend/core/conf.py b/backend/core/conf.py index 73ffbbbc6..596ae6b93 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -89,11 +89,12 @@ class Settings(BaseSettings): # 数据权限配置 DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义 - 'Api': 'backend.plugin.casbin.model.Api', + '部门': 'backend.app.admin.model.Dept', } DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列 'id', 'sort', + 'del_flag', 'created_time', 'updated_time', ] diff --git a/backend/middleware/jwt_auth_middleware.py b/backend/middleware/jwt_auth_middleware.py index 537ee7804..9dd67a962 100644 --- a/backend/middleware/jwt_auth_middleware.py +++ b/backend/middleware/jwt_auth_middleware.py @@ -71,7 +71,7 @@ async def authenticate(self, request: Request) -> tuple[AuthCredentials, GetUser except TokenError as exc: raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers) except Exception as e: - log.error(f'JWT 授权异常:{e}') + log.exception(f'JWT 授权异常:{e}') raise _AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error')) # 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性 diff --git a/backend/sql/mysql/init_test_data.sql b/backend/sql/mysql/init_test_data.sql index 8905f0e47..c23dc0aae 100644 --- a/backend/sql/mysql/init_test_data.sql +++ b/backend/sql/mysql/init_test_data.sql @@ -1,11 +1,6 @@ insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time) values (1, 'test', 0, null, null, null, 1, 0, null, '2023-06-26 17:13:45', null); -insert into sys_api (id, name, method, path, remark, created_time, updated_time) -values (1, '创建API', 'POST', '/api/v1/apis', null, '2024-02-02 11:29:47', null), - (2, '删除API', 'DELETE', '/api/v1/apis', null, '2024-02-02 11:31:32', null), - (3, '编辑API', 'PUT', '/api/v1/apis/{pk}', null, '2024-02-02 11:32:22', null); - insert into fba.sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) values (1, '测试', 'Test', 'test', 0, null, 0, null, null, 0, 0, 1, null, null, null, '2023-07-27 19:14:10', null), (2, '仪表盘', 'Dashboard', 'dashboard', 0, 'material-symbols:dashboard', 0, null, null, 1, 1, 1, null, null, null, '2023-07-27 19:15:45', null), diff --git a/backend/sql/postgresql/init_test_data.sql b/backend/sql/postgresql/init_test_data.sql index a4873d647..55f84d742 100644 --- a/backend/sql/postgresql/init_test_data.sql +++ b/backend/sql/postgresql/init_test_data.sql @@ -1,11 +1,6 @@ insert into sys_dept (id, name, sort, leader, phone, email, status, del_flag, parent_id, created_time, updated_time) values (1, 'test', 0, null, null, null, 1, 0, null, '2023-06-26 17:13:45', null); -insert into sys_api (id, name, method, path, remark, created_time, updated_time) -values (1, '创建API', 'POST', '/api/v1/apis', null, '2024-02-02 11:29:47', null), - (2, '删除API', 'DELETE', '/api/v1/apis', null, '2024-02-02 11:31:32', null), - (3, '编辑API', 'PUT', '/api/v1/apis/{pk}', null, '2024-02-02 11:32:22', null); - insert into fba.sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) values (1, '测试', 'Test', 'test', 0, null, 0, null, null, 0, 0, 1, null, null, null, '2023-07-27 19:14:10', null), (2, '仪表盘', 'Dashboard', 'dashboard', 0, 'material-symbols:dashboard', 0, null, null, 1, 1, 1, null, null, null, '2023-07-27 19:15:45', null), diff --git a/backend/utils/health_check.py b/backend/utils/health_check.py index 1a68a1509..bbe0dd764 100644 --- a/backend/utils/health_check.py +++ b/backend/utils/health_check.py @@ -1,11 +1,17 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import asyncio +import functools +import time + from math import ceil +from typing import Any, Callable from fastapi import FastAPI, Request, Response from fastapi.routing import APIRoute from backend.common.exception import errors +from backend.common.log import log def ensure_unique_route_names(app: FastAPI) -> None: @@ -34,3 +40,34 @@ async def http_limit_callback(request: Request, response: Response, expire: int) """ expires = ceil(expire / 1000) raise errors.HTTPError(code=429, msg='请求过于频繁,请稍后重试', headers={'Retry-After': str(expires)}) + + +def timer(func) -> Callable: + """函数耗时计时装饰器""" + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs) -> Any: + start_time = time.perf_counter() + result = await func(*args, **kwargs) + elapsed_seconds = time.perf_counter() - start_time + _log_time(func, elapsed_seconds) + return result + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs) -> Any: + start_time = time.perf_counter() + result = func(*args, **kwargs) + elapsed_seconds = time.perf_counter() - start_time + _log_time(func, elapsed_seconds) + return result + + def _log_time(func, elapsed: float): + # 智能选择单位(秒、毫秒、微秒、纳秒) + if elapsed >= 1: + unit, factor = 's', 1 + else: + unit, factor = 'ms', 1e3 + + log.info(f'{func.__module__}.{func.__name__} | {elapsed * factor:.3f} {unit}') + + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper diff --git a/pyproject.toml b/pyproject.toml index a47dd3f82..d783a177c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "python-socketio>=5.12.0", "redis[hiredis]>=5.2.0", "rtoml>=0.12.0", - "sqlalchemy-crud-plus==1.6.0", + "sqlalchemy-crud-plus>=1.8.0", "sqlalchemy[asyncio]>=2.0.40", "user-agents==2.2.0", ] diff --git a/requirements.txt b/requirements.txt index fdacda1ec..721a084d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -95,7 +95,7 @@ simple-websocket==1.1.0 six==1.17.0 sniffio==1.3.1 sqlalchemy==2.0.40 -sqlalchemy-crud-plus==1.6.0 +sqlalchemy-crud-plus==1.8.0 starlette==0.46.1 termcolor==2.5.0 tomli==2.2.1 ; python_full_version < '3.11' diff --git a/uv.lock b/uv.lock index 80ffeb771..a3f63b8ba 100644 --- a/uv.lock +++ b/uv.lock @@ -634,7 +634,7 @@ requires-dist = [ { name = "redis", extras = ["hiredis"], specifier = ">=5.2.0" }, { name = "rtoml", specifier = ">=0.12.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.40" }, - { name = "sqlalchemy-crud-plus", specifier = "==1.6.0" }, + { name = "sqlalchemy-crud-plus", specifier = ">=1.8.0" }, { name = "user-agents", specifier = "==2.2.0" }, ] @@ -1863,15 +1863,15 @@ asyncio = [ [[package]] name = "sqlalchemy-crud-plus" -version = "1.6.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/37/6a/99d1908c96ba13da4941e7fa6e254220a14332a2dce244fa423487ea7759/sqlalchemy_crud_plus-1.6.0.tar.gz", hash = "sha256:a09a56c4a9dd909800b4be5868a72b985b1cffd548aa734e3e2199c3395d2a55" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/56/57a19b9a55910f73c80e802c42e9374f83752e6bfb5ad46829cf99e07758/sqlalchemy_crud_plus-1.8.0.tar.gz", hash = "sha256:cda7fc71a07887ac6fbbc423c0e061dfb912063b4ae207eccba31e08dd87aabb", size = 41807 } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d0/e9/0996d7e9be20473f4a80a1f8a5d679bfcbc2e5a567b2f0ca488ccaa2b475/sqlalchemy_crud_plus-1.6.0-py3-none-any.whl", hash = "sha256:b2c77957716023f1ab4beac27588c448b9f9fba12657aa4013b1946d26fffd84" }, + { url = "https://files.pythonhosted.org/packages/f4/25/7bb0ecc055dee08e18682f67af26671c33974722f477dd74c1cc1400c6f8/sqlalchemy_crud_plus-1.8.0-py3-none-any.whl", hash = "sha256:15ac0c6ce83df3b89585f05df25a8b5e35baf846d3a20fd8305fb1ff58aef65b", size = 8458 }, ] [[package]]