diff --git a/fastapi_admin/app.py b/fastapi_admin/app.py index f19c806..8f5397d 100644 --- a/fastapi_admin/app.py +++ b/fastapi_admin/app.py @@ -2,15 +2,18 @@ from __future__ import annotations +import mimetypes import sys from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import Response from fastapi_admin.config import APP_NAME, APP_CORS_ORIGINS from fastapi_admin.config._loader import _find_project_root +from fastapi_common.fastapi_common_storage.oss_client import OssClient # 确保项目根在 sys.path _PROJECT_ROOT = _find_project_root() @@ -62,6 +65,22 @@ def create_app() -> FastAPI: from fastapi_admin.bootstrap_parts.controllers import register_controllers register_controllers(app) + @app.get("/docauditai/{ObjectPath:path}") + async def GetCompatObject(ObjectPath: str): + """兼容旧前端的对象访问路径,优先读取新桶对象。""" + Client = OssClient() + CandidateBuckets = [Client.bucket, "docauditai"] + for BucketName in CandidateBuckets: + try: + if not Client.ObjectExists(ObjectPath, Bucket=BucketName): + continue + Content = Client.DownloadBytes(ObjectPath, Bucket=BucketName) + MediaType = mimetypes.guess_type(ObjectPath)[0] or "application/octet-stream" + return Response(content=Content, media_type=MediaType) + except Exception: + continue + raise HTTPException(status_code=404, detail="Object not found") + return app diff --git a/fastapi_admin/bootstrap_parts/controllers.py b/fastapi_admin/bootstrap_parts/controllers.py index bd9d02a..4274c61 100644 --- a/fastapi_admin/bootstrap_parts/controllers.py +++ b/fastapi_admin/bootstrap_parts/controllers.py @@ -61,9 +61,19 @@ def _register_from_package( pkg_dir = str(Path(pkg.__file__ or "").parent) - for _, module_name, _ in pkgutil.iter_modules([pkg_dir]): + for _, module_name, is_pkg in pkgutil.iter_modules([pkg_dir]): if module_name.startswith("_"): continue + + if is_pkg: + try: + sub_pkg_name = f"{pkg_name}.{module_name}" + sub_pkg = importlib.import_module(sub_pkg_name) + _register_from_package(sub_pkg, sub_pkg_name, package_routers, app) + except ImportError: + continue + continue + try: mod = importlib.import_module(f"{pkg_name}.{module_name}") except ImportError: diff --git a/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py b/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py index f5d57f3..6270de1 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/auth/authController.py @@ -84,7 +84,7 @@ class AuthController(BaseController): except LeauditException as e: logger.error(f"登录失败: {e.message}") return JSONResponse( - status_code=e.statusCode, + status_code=e.status.value, content={"success": False, "message": e.message, "data": None}, ) except Exception as e: @@ -106,7 +106,7 @@ class AuthController(BaseController): except LeauditException as e: logger.error(f"密码登录失败: {e.message}") return JSONResponse( - status_code=e.statusCode, + status_code=e.status.value, content={"success": False, "message": e.message, "data": None}, ) except Exception as e: diff --git a/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py b/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py new file mode 100644 index 0000000..c12ff2f --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/entryModuleController.py @@ -0,0 +1,85 @@ +"""入口模块管理控制器。""" + +from fastapi import Depends, File, Query, UploadFile +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_modules.fastapi_leaudit.domian.Dto.entryModuleDto import EntryModuleCreateDTO, EntryModuleUpdateDTO +from fastapi_modules.fastapi_leaudit.services.entryModuleAdminService import IEntryModuleAdminService +from fastapi_modules.fastapi_leaudit.services.impl.entryModuleAdminServiceImpl import EntryModuleAdminServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService + + +class EntryModuleController(BaseController): + """入口模块管理控制器。""" + + def __init__(self): + super().__init__(prefix="/v3/entry-modules", tags=["入口模块管理"]) + self.EntryModuleService: IEntryModuleAdminService = EntryModuleAdminServiceImpl() + self.PermissionService: IPermissionService = PermissionServiceImpl() + + @self.router.get("") + async def GetEntryModules( + name: str | None = Query(None, description="模块名称模糊搜索"), + area: str | None = Query(None, description="地区筛选"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(10, ge=1, le=200, description="每页数量"), + payload: dict = Depends(verify_access_token), + ): + """查询入口模块列表。""" + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "entry_module:list:read"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有入口模块列表权限", "data": None}) + data = await self.EntryModuleService.ListModules(Name=name, Area=area, Page=page, PageSize=page_size) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data.model_dump()}) + + @self.router.get("/{ModuleId}") + async def GetEntryModule(ModuleId: int, payload: dict = Depends(verify_access_token)): + """查询入口模块详情。""" + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "entry_module:detail:read"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有入口模块详情权限", "data": None}) + data = await self.EntryModuleService.GetModule(ModuleId) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data.model_dump()}) + + @self.router.post("") + async def CreateEntryModule(Body: EntryModuleCreateDTO, payload: dict = Depends(verify_access_token)): + """创建入口模块。""" + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "entry_module:create:write"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建入口模块权限", "data": None}) + data = await self.EntryModuleService.CreateModule(Body) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data.model_dump()}) + + @self.router.put("/{ModuleId}") + async def UpdateEntryModule(ModuleId: int, Body: EntryModuleUpdateDTO, payload: dict = Depends(verify_access_token)): + """更新入口模块。""" + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "entry_module:update:write"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新入口模块权限", "data": None}) + data = await self.EntryModuleService.UpdateModule(ModuleId, Body) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data.model_dump()}) + + @self.router.delete("/{ModuleId}") + async def DeleteEntryModule(ModuleId: int, payload: dict = Depends(verify_access_token)): + """删除入口模块。""" + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "entry_module:delete:delete"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除入口模块权限", "data": None}) + await self.EntryModuleService.DeleteModule(ModuleId) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": {"message": "删除成功"}}) + + @self.router.post("/{ModuleId}/image") + async def UploadEntryModuleImage( + ModuleId: int, + file: UploadFile = File(..., description="入口模块图标"), + payload: dict = Depends(verify_access_token), + ): + """上传入口模块图标。""" + if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "entry_module:image:write"): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有上传入口模块图标权限", "data": None}) + content = await file.read() + data = await self.EntryModuleService.UploadModuleImage( + ModuleId=ModuleId, + FileName=file.filename or f"entry_module_{ModuleId}.png", + ContentType=file.content_type or "application/octet-stream", + Content=content, + ) + return JSONResponse(status_code=200, content={"code": 0, "msg": "success", "data": data.model_dump()}) diff --git a/fastapi_modules/fastapi_leaudit/controllers/homeController.py b/fastapi_modules/fastapi_leaudit/controllers/homeController.py new file mode 100644 index 0000000..4b1ef42 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/homeController.py @@ -0,0 +1,27 @@ +"""首页入口控制器。""" + +from typing import Any + +from fastapi import Depends + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_common.fastapi_common_web.domain.responses import Result + +from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeEntryModuleVO +from fastapi_modules.fastapi_leaudit.services import IHomeService +from fastapi_modules.fastapi_leaudit.services.impl.homeServiceImpl import HomeServiceImpl + + +class HomeController(BaseController): + """首页入口控制器。""" + + def __init__(self): + super().__init__(prefix="/home", tags=["首页"]) + self.HomeService: IHomeService = HomeServiceImpl() + + @self.router.get("/entry-modules", response_model=Result[list[HomeEntryModuleVO]]) + async def GetEntryModules(payload: dict[str, Any] = Depends(verify_access_token)): + """获取当前用户可见的首页入口模块。""" + Data = await self.HomeService.GetEntryModules(UserId=int(payload["user_id"])) + return Result.success(data=Data) diff --git a/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py b/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py new file mode 100644 index 0000000..3ee4e24 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/rbacAdminController.py @@ -0,0 +1,135 @@ +"""RBAC 管理控制器。""" + +from typing import Any + +from fastapi import Depends, Query +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController + +from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import RoleCreateDTO, RolePermissionsBatchDTO, RoleRoutesUpdateDTO, RoleUpdateDTO, UserRolesAssignDTO +from fastapi_modules.fastapi_leaudit.services.impl.rbacAdminServiceImpl import RbacAdminServiceImpl +from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService + + +class RbacAdminController(BaseController): + """RBAC 管理控制器。""" + + def __init__(self): + super().__init__(prefix="", tags=["RBAC管理"]) + self.RbacAdminService: IRbacAdminService = RbacAdminServiceImpl() + + @self.router.get("/v3/rbac/roles") + async def GetRoles( + payload: dict[str, Any] = Depends(verify_access_token), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + role_key: str | None = Query(None), + role_name: str | None = Query(None), + include_system: bool = Query(True), + ): + """查询角色列表。""" + data = await self.RbacAdminService.ListRoles(int(payload["user_id"]), page, page_size, role_key, role_name, include_system) + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) + + @self.router.post("/v3/rbac/roles") + async def CreateRole(Body: RoleCreateDTO, payload: dict[str, Any] = Depends(verify_access_token)): + """创建角色。""" + data = await self.RbacAdminService.CreateRole(int(payload["user_id"]), Body) + return JSONResponse(status_code=200, content={"code": 200, "message": "角色创建成功", "data": data.model_dump()}) + + @self.router.put("/v3/rbac/roles/{RoleId}") + async def UpdateRole(RoleId: int, Body: RoleUpdateDTO, payload: dict[str, Any] = Depends(verify_access_token)): + """更新角色。""" + data = await self.RbacAdminService.UpdateRole(int(payload["user_id"]), RoleId, Body) + return JSONResponse(status_code=200, content={"code": 200, "message": "角色更新成功", "data": data.model_dump()}) + + @self.router.delete("/v3/rbac/roles/{RoleId}") + async def DeleteRole(RoleId: int, force: bool = Query(False), payload: dict[str, Any] = Depends(verify_access_token)): + """删除角色。""" + await self.RbacAdminService.DeleteRole(int(payload["user_id"]), RoleId, force) + return JSONResponse(status_code=200, content={"code": 200, "message": "角色删除成功", "data": {}}) + + @self.router.get("/v3/rbac/users") + async def GetUsers( + payload: dict[str, Any] = Depends(verify_access_token), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + area: str | None = Query(None), + nick_name: str | None = Query(None), + ): + """查询用户列表。""" + data = await self.RbacAdminService.ListUsers(int(payload["user_id"]), page, page_size, area, nick_name) + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) + + @self.router.get("/v3/rbac/roles/{RoleId}/users") + async def GetRoleUsers( + RoleId: int, + payload: dict[str, Any] = Depends(verify_access_token), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + area: str | None = Query(None), + username: str | None = Query(None), + ): + """查询指定角色下的用户列表。""" + data = await self.RbacAdminService.ListRoleUsers(int(payload["user_id"]), RoleId, page, page_size, area, username) + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) + + @self.router.post("/v3/rbac/users/{UserId}/roles") + async def AssignUserRoles(UserId: int, Body: UserRolesAssignDTO, payload: dict[str, Any] = Depends(verify_access_token)): + """分配用户角色。""" + data = await self.RbacAdminService.AssignUserRoles(int(payload["user_id"]), UserId, Body.role_ids) + return JSONResponse(status_code=200, content={"code": 200, "message": "角色分配成功", "data": data.model_dump()}) + + @self.router.delete("/v3/rbac/users/{UserId}/roles/{RoleId}") + async def RevokeUserRole(UserId: int, RoleId: int, payload: dict[str, Any] = Depends(verify_access_token)): + """移除用户角色。""" + await self.RbacAdminService.RevokeUserRole(int(payload["user_id"]), UserId, RoleId) + return JSONResponse(status_code=200, content={"code": 200, "message": "角色移除成功", "data": {}}) + + @self.router.get("/v3/rbac/users/{UserId}/roles") + async def GetUserRoles(UserId: int, payload: dict[str, Any] = Depends(verify_access_token)): + """查询用户角色。""" + data = await self.RbacAdminService.GetUserRoles(int(payload["user_id"]), UserId) + return JSONResponse(status_code=200, content={"code": 200, "msg": "success", "data": data.model_dump()}) + + @self.router.get("/v3/routes") + async def GetAllRoutes( + payload: dict[str, Any] = Depends(verify_access_token), + format: str = Query("tree"), + include_hidden: bool = Query(False), + ): + """查询全部可管理路由。""" + data = await self.RbacAdminService.ListAllRoutes(int(payload["user_id"]), format, include_hidden) + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": [item.model_dump() for item in data]}) + + @self.router.get("/rbac/roles/{RoleId}/routes") + async def GetRoleRoutes(RoleId: int, payload: dict[str, Any] = Depends(verify_access_token)): + """查询角色路由授权。""" + data = await self.RbacAdminService.GetRoleRoutes(int(payload["user_id"]), RoleId) + return JSONResponse(status_code=200, content={"code": 200, "msg": "success", "data": data.model_dump()}) + + @self.router.put("/rbac/roles/{RoleId}/routes") + async def UpdateRoleRoutes(RoleId: int, Body: RoleRoutesUpdateDTO, payload: dict[str, Any] = Depends(verify_access_token)): + """更新角色路由授权。""" + data = await self.RbacAdminService.UpdateRoleRoutes(int(payload["user_id"]), RoleId, Body) + return JSONResponse(status_code=200, content={"code": 200, "msg": "success", "data": data.model_dump()}) + + @self.router.get("/v3/rbac/role-permissions") + async def GetRolePermissions(role_id: int = Query(...), payload: dict[str, Any] = Depends(verify_access_token)): + """查询角色权限授权。""" + data = await self.RbacAdminService.GetRolePermissions(int(payload["user_id"]), role_id) + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) + + @self.router.post("/v3/rbac/role-permissions") + async def SaveRolePermissions(Body: RolePermissionsBatchDTO, payload: dict[str, Any] = Depends(verify_access_token)): + """保存角色权限授权。""" + data = await self.RbacAdminService.SaveRolePermissions(int(payload["user_id"]), Body) + return JSONResponse(status_code=200, content={"code": 200, "message": "权限分配成功", "data": data.model_dump()}) + + @self.router.get("/v3/routes/{RouteId}/permissions") + async def GetRoutePermissions(RouteId: int, payload: dict[str, Any] = Depends(verify_access_token)): + """查询路由关联权限。""" + data = await self.RbacAdminService.GetRoutePermissions(int(payload["user_id"]), RouteId) + return JSONResponse(status_code=200, content={"code": 200, "message": "success", "data": data.model_dump()}) diff --git a/fastapi_modules/fastapi_leaudit/controllers/rbacController.py b/fastapi_modules/fastapi_leaudit/controllers/rbacController.py new file mode 100644 index 0000000..0c2e707 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/rbacController.py @@ -0,0 +1,27 @@ +"""RBAC 路由控制器。""" + +from typing import Any + +from fastapi import Depends + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_common.fastapi_common_web.domain.responses import Result + +from fastapi_modules.fastapi_leaudit.domian.vo.rbacVo import RbacUserRoutesVO +from fastapi_modules.fastapi_leaudit.services import IRbacService +from fastapi_modules.fastapi_leaudit.services.impl.rbacServiceImpl import RbacServiceImpl + + +class RbacController(BaseController): + """RBAC 路由控制器。""" + + def __init__(self): + super().__init__(prefix="/rbac", tags=["RBAC"]) + self.RbacService: IRbacService = RbacServiceImpl() + + @self.router.get("/user/routes", response_model=Result[RbacUserRoutesVO]) + async def GetCurrentUserRoutes(payload: dict[str, Any] = Depends(verify_access_token)): + """获取当前登录用户可访问的前端路由树。""" + Data = await self.RbacService.GetCurrentUserRoutes(UserId=int(payload["user_id"])) + return Result.success(data=Data) diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/entryModuleDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/entryModuleDto.py new file mode 100644 index 0000000..972bd9d --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/entryModuleDto.py @@ -0,0 +1,29 @@ +"""入口模块管理 DTO。""" + +from pydantic import BaseModel, Field + + +class EntryModuleAreaDTO(BaseModel): + """入口模块地区配置。""" + + area: str = Field(..., description="地区名称") + enabled: bool = Field(True, description="是否启用") + sort_order: int = Field(0, description="排序号") + + +class EntryModuleCreateDTO(BaseModel): + """创建入口模块请求。""" + + name: str = Field(..., description="模块名称") + description: str | None = Field(None, description="模块描述") + path: str | None = Field(None, description="前端路由路径") + areas: list[EntryModuleAreaDTO] | None = Field(None, description="地区配置") + + +class EntryModuleUpdateDTO(BaseModel): + """更新入口模块请求。""" + + name: str | None = Field(None, description="模块名称") + description: str | None = Field(None, description="模块描述") + path: str | None = Field(None, description="前端路由路径") + areas: list[EntryModuleAreaDTO] | None = Field(None, description="地区配置") diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py new file mode 100644 index 0000000..efff1ba --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/rbacAdminDto.py @@ -0,0 +1,52 @@ +"""RBAC 管理 DTO。""" + +from pydantic import BaseModel, Field + + +class RoleCreateDTO(BaseModel): + """创建角色请求。""" + + role_key: str = Field(..., description="角色唯一标识") + role_name: str = Field(..., description="角色名称") + description: str | None = Field(None, description="角色描述") + data_scope: str = Field("SELF", description="数据范围") + metadata: dict | None = Field(None, description="扩展元数据") + + +class RoleUpdateDTO(BaseModel): + """更新角色请求。""" + + role_name: str | None = Field(None, description="角色名称") + description: str | None = Field(None, description="角色描述") + data_scope: str | None = Field(None, description="数据范围") + priority: int | None = Field(None, description="优先级") + parent_role_id: int | None = Field(None, description="父角色ID") + + +class RoleRoutesUpdateDTO(BaseModel): + """更新角色路由授权请求。""" + + route_ids: list[int] = Field(default_factory=list, description="启用路由ID列表") + permission: str = Field("RW", description="路由授权类型") + + +class RolePermissionConfigDTO(BaseModel): + """角色权限配置。""" + + permission_id: int = Field(..., description="权限ID") + grant_type: str = Field("GRANT", description="授权类型") + data_scope: str | None = Field(None, description="数据范围") + + +class RolePermissionsBatchDTO(BaseModel): + """批量写入角色权限请求。""" + + role_id: int = Field(..., description="角色ID") + permissions: list[RolePermissionConfigDTO] = Field(default_factory=list, description="权限列表") + replace: bool = Field(False, description="是否替换当前角色已有权限") + + +class UserRolesAssignDTO(BaseModel): + """用户角色分配请求。""" + + role_ids: list[int] = Field(default_factory=list, description="角色ID列表") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/entryModuleAdminVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/entryModuleAdminVo.py new file mode 100644 index 0000000..a448b3c --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/entryModuleAdminVo.py @@ -0,0 +1,44 @@ +"""入口模块管理 VO。""" + +from pydantic import BaseModel, Field + + +class EntryModuleAreaVO(BaseModel): + """入口模块地区配置。""" + + area: str = Field(..., description="地区名称") + enabled: bool = Field(True, description="是否启用") + sort_order: int = Field(0, description="排序号") + + +class EntryModuleVO(BaseModel): + """入口模块详情。""" + + id: int = Field(..., description="入口模块ID") + name: str = Field(..., description="模块名称") + description: str | None = Field(None, description="模块描述") + path: str | None = Field(None, description="图标路径") + route_path: str | None = Field(None, description="前端跳转路径") + sort_order: int = Field(0, description="排序") + is_enabled: bool = Field(True, description="是否启用") + areas: list[EntryModuleAreaVO] = Field(default_factory=list, description="地区配置") + created_at: str | None = Field(None, description="创建时间") + updated_at: str | None = Field(None, description="更新时间") + + +class EntryModuleListVO(BaseModel): + """入口模块列表分页。""" + + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + page_size: int = Field(10, description="分页大小") + items: list[EntryModuleVO] = Field(default_factory=list, description="入口模块列表") + + +class EntryModuleImageUploadVO(BaseModel): + """入口模块图片上传结果。""" + + module_id: int = Field(..., description="模块ID") + path: str = Field(..., description="对象路径") + url: str = Field(..., description="访问地址") + message: str = Field(..., description="结果消息") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/homeVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/homeVo.py new file mode 100644 index 0000000..4ab0bb6 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/homeVo.py @@ -0,0 +1,34 @@ +"""首页入口 VO。""" + +from pydantic import BaseModel, Field + + +class HomeEntryAreaVO(BaseModel): + """入口模块地区配置。""" + + area: str = Field(..., description="地区名称") + enabled: bool = Field(..., description="是否启用") + sortOrder: int = Field(0, description="地区内排序") + + +class HomeEntryDocumentTypeVO(BaseModel): + """入口模块下的文档类型。""" + + id: int = Field(..., description="文档类型ID") + name: str = Field(..., description="文档类型名称") + code: str | None = Field(None, description="文档类型编码") + + +class HomeEntryModuleVO(BaseModel): + """首页入口模块。""" + + id: int = Field(..., description="入口模块ID") + name: str = Field(..., description="模块名称") + description: str | None = Field(None, description="模块描述") + targetPath: str | None = Field(None, description="点击后跳转路径") + routePath: str | None = Field(None, description="用于 RBAC 校验的页面路径") + iconPath: str | None = Field(None, description="模块图标路径") + sortOrder: int = Field(0, description="排序序号") + requiresDocumentTypes: bool = Field(True, description="是否要求至少绑定一个文档类型") + areas: list[HomeEntryAreaVO] = Field(default_factory=list, description="地区配置") + documentTypes: list[HomeEntryDocumentTypeVO] = Field(default_factory=list, description="关联文档类型列表") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py new file mode 100644 index 0000000..b9bc0ac --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/rbacAdminVo.py @@ -0,0 +1,151 @@ +"""RBAC 管理 VO。""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class RoleVO(BaseModel): + """角色信息。""" + + id: int = Field(..., description="角色ID") + role_key: str = Field(..., description="角色标识") + role_name: str = Field(..., description="角色名称") + data_scope: str = Field(..., description="数据范围") + description: str = Field("", description="角色描述") + parent_role_id: int | None = Field(None, description="父角色ID") + priority: int = Field(0, description="优先级") + is_system: bool = Field(False, description="是否系统角色") + created_at: str | None = Field(None, description="创建时间") + updated_at: str | None = Field(None, description="更新时间") + + +class RoleListVO(BaseModel): + """角色列表分页。""" + + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + page_size: int = Field(50, description="分页大小") + items: list[RoleVO] = Field(default_factory=list, description="角色列表") + + +class UserRoleVO(BaseModel): + """用户角色。""" + + role_id: int = Field(..., description="角色ID") + role_key: str = Field(..., description="角色标识") + role_name: str = Field(..., description="角色名称") + + +class UserVO(BaseModel): + """用户信息。""" + + id: int = Field(..., description="用户ID") + username: str = Field(..., description="用户名") + nick_name: str = Field(..., description="姓名") + phone_number: str | None = Field(None, description="手机号") + email: str | None = Field(None, description="邮箱") + area: str | None = Field(None, description="地区") + ou_name: str | None = Field(None, description="组织名称") + ou_id: str | None = Field(None, description="组织ID") + status: int = Field(0, description="状态") + is_leader: bool = Field(False, description="是否负责人") + roles: list[UserRoleVO] = Field(default_factory=list, description="角色列表") + tenant_name: str | None = Field(None, description="租户名称") + dep_name: str | None = Field(None, description="部门名称") + + +class UserListVO(BaseModel): + """用户列表分页。""" + + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + page_size: int = Field(50, description="分页大小") + items: list[UserVO] = Field(default_factory=list, description="用户列表") + + +class RoutePermissionVO(BaseModel): + """路由关联的 API 权限。""" + + id: int = Field(..., description="权限ID") + permission_key: str = Field(..., description="权限键") + display_name: str | None = Field(None, description="显示名称") + api_method: str | None = Field(None, description="请求方法") + api_path: str | None = Field(None, description="接口路径") + route_id: int | None = Field(None, description="主路由ID") + related_routes: list[int] | None = Field(None, description="共享路由ID列表") + is_shared: bool = Field(False, description="是否共享权限") + + +class RouteVO(BaseModel): + """RBAC 管理路由。""" + + id: int = Field(..., description="路由ID") + route_path: str = Field(..., description="路径") + route_name: str = Field(..., description="名称") + route_title: str = Field(..., description="标题") + component: str | None = Field(None, description="组件") + parent_id: int | None = Field(None, description="父ID") + icon: str | None = Field(None, description="图标") + sort_order: int = Field(0, description="排序") + is_hidden: bool = Field(False, description="是否隐藏") + is_cache: bool = Field(True, description="是否缓存") + status: int = Field(0, description="状态") + enabled: bool = Field(False, description="角色是否启用该路由") + permissions: list[RoutePermissionVO] = Field(default_factory=list, description="关联权限") + children: list["RouteVO"] | None = Field(None, description="子路由") + + +class RoleRoutesVO(BaseModel): + """角色路由树响应。""" + + role_id: int = Field(..., description="角色ID") + routes: list[RouteVO] = Field(default_factory=list, description="路由树") + + +class RoleRouteUpdateResultVO(BaseModel): + """角色路由更新结果。""" + + role_id: int = Field(..., description="角色ID") + enabled_count: int = Field(0, description="启用数量") + disabled_count: int = Field(0, description="禁用数量") + inserted_count: int = Field(0, description="新增数量") + route_ids: list[int] = Field(default_factory=list, description="启用路由ID") + + +class RolePermissionDetailVO(BaseModel): + """角色权限详情。""" + + id: int = Field(..., description="角色权限关联ID") + permission_id: int = Field(..., description="权限ID") + permission_key: str = Field(..., description="权限键") + display_name: str | None = Field(None, description="显示名") + grant_type: str = Field("GRANT", description="授权类型") + data_scope: str | None = Field(None, description="数据范围") + + +class RolePermissionsVO(BaseModel): + """角色权限响应。""" + + role_id: int = Field(..., description="角色ID") + permissions: list[RolePermissionDetailVO] = Field(default_factory=list, description="权限列表") + + +class UserRolesVO(BaseModel): + """用户角色响应。""" + + user_id: int = Field(..., description="用户ID") + username: str = Field(..., description="用户名") + roles: list[RoleVO] = Field(default_factory=list, description="角色列表") + + +class RoutePermissionsVO(BaseModel): + """路由权限响应。""" + + route_id: int = Field(..., description="路由ID") + route_path: str = Field(..., description="路由路径") + route_title: str = Field(..., description="路由标题") + permissions: list[RoutePermissionVO] = Field(default_factory=list, description="权限列表") + + +RouteVO.model_rebuild() diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/rbacVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/rbacVo.py new file mode 100644 index 0000000..7f360f2 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/rbacVo.py @@ -0,0 +1,35 @@ +"""RBAC 路由 VO。""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class RbacRouteVO(BaseModel): + """当前用户可访问的前端路由。""" + + id: int = Field(..., description="路由ID") + route_path: str = Field(..., description="前端路由路径") + route_name: str = Field(..., description="路由名称") + component: str | None = Field(None, description="前端组件路径") + parent_id: int | None = Field(None, description="父路由ID") + route_title: str = Field(..., description="路由标题") + icon: str | None = Field(None, description="路由图标") + sort_order: int = Field(0, description="排序顺序") + is_hidden: bool = Field(False, description="是否隐藏") + is_cache: bool = Field(False, description="是否开启缓存") + meta: dict | None = Field(None, description="路由扩展元数据") + permissions: list[str] = Field(default_factory=list, description="当前用户在该路由下拥有的权限列表") + children: list["RbacRouteVO"] | None = Field(None, description="子路由") + + +class RbacUserRoutesVO(BaseModel): + """当前用户路由响应。""" + + user_id: int = Field(..., description="用户ID") + username: str = Field(..., description="用户名") + roles: list[str] = Field(default_factory=list, description="用户角色列表") + routes: list[RbacRouteVO] = Field(default_factory=list, description="用户可访问路由树") + + +RbacRouteVO.model_rebuild() diff --git a/fastapi_modules/fastapi_leaudit/services/__init__.py b/fastapi_modules/fastapi_leaudit/services/__init__.py index c9eaead..c21f0d0 100644 --- a/fastapi_modules/fastapi_leaudit/services/__init__.py +++ b/fastapi_modules/fastapi_leaudit/services/__init__.py @@ -2,9 +2,24 @@ from fastapi_modules.fastapi_leaudit.services.auditService import IAuditService from fastapi_modules.fastapi_leaudit.services.documentService import IDocumentService +from fastapi_modules.fastapi_leaudit.services.entryModuleAdminService import IEntryModuleAdminService from fastapi_modules.fastapi_leaudit.services.authService import IAuthService +from fastapi_modules.fastapi_leaudit.services.homeService import IHomeService from fastapi_modules.fastapi_leaudit.services.ossService import IOssService from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService +from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService +from fastapi_modules.fastapi_leaudit.services.rbacService import IRbacService from fastapi_modules.fastapi_leaudit.services.ruleService import IRuleService -__all__ = ["IAuditService", "IDocumentService", "IAuthService", "IOssService", "IPermissionService", "IRuleService"] +__all__ = [ + "IAuditService", + "IDocumentService", + "IEntryModuleAdminService", + "IAuthService", + "IHomeService", + "IOssService", + "IPermissionService", + "IRbacAdminService", + "IRbacService", + "IRuleService", +] diff --git a/fastapi_modules/fastapi_leaudit/services/entryModuleAdminService.py b/fastapi_modules/fastapi_leaudit/services/entryModuleAdminService.py new file mode 100644 index 0000000..fb3d8e9 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/entryModuleAdminService.py @@ -0,0 +1,40 @@ +"""入口模块管理服务接口。""" + +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.Dto.entryModuleDto import EntryModuleCreateDTO, EntryModuleUpdateDTO +from fastapi_modules.fastapi_leaudit.domian.vo.entryModuleAdminVo import EntryModuleImageUploadVO, EntryModuleListVO, EntryModuleVO + + +class IEntryModuleAdminService(ABC): + """入口模块管理服务接口。""" + + @abstractmethod + async def ListModules(self, Name: str | None, Area: str | None, Page: int, PageSize: int) -> EntryModuleListVO: + """分页查询入口模块。""" + ... + + @abstractmethod + async def GetModule(self, ModuleId: int) -> EntryModuleVO: + """获取入口模块详情。""" + ... + + @abstractmethod + async def CreateModule(self, Body: EntryModuleCreateDTO) -> EntryModuleVO: + """创建入口模块。""" + ... + + @abstractmethod + async def UpdateModule(self, ModuleId: int, Body: EntryModuleUpdateDTO) -> EntryModuleVO: + """更新入口模块。""" + ... + + @abstractmethod + async def DeleteModule(self, ModuleId: int) -> None: + """删除入口模块。""" + ... + + @abstractmethod + async def UploadModuleImage(self, ModuleId: int, FileName: str, ContentType: str, Content: bytes) -> EntryModuleImageUploadVO: + """上传入口模块图标。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/homeService.py b/fastapi_modules/fastapi_leaudit/services/homeService.py new file mode 100644 index 0000000..6849c44 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/homeService.py @@ -0,0 +1,14 @@ +"""首页入口服务接口。""" + +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeEntryModuleVO + + +class IHomeService(ABC): + """首页入口服务接口。""" + + @abstractmethod + async def GetEntryModules(self, UserId: int) -> list[HomeEntryModuleVO]: + """获取当前用户可见的首页入口模块。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py index 8cffea2..5fd4675 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/authServiceImpl.py @@ -18,6 +18,11 @@ from fastapi_modules.fastapi_leaudit.services.authService import IAuthService class AuthServiceImpl(IAuthService): """认证服务实现。""" + @staticmethod + def _naive_utcnow() -> datetime: + """返回适配 timestamp without time zone 的 UTC 时间。""" + return datetime.utcnow() + async def PasswordLogin(self, Sub: str, Password: str) -> LoginTokenVO: """账密登录。 @@ -112,7 +117,7 @@ class AuthServiceImpl(IAuthService): "ou_id": OuId or user.get("ou_id") or "", "ou_name": OuName or user.get("ou_name") or "", "is_leader": IsLeader if IsLeader is not None else user.get("is_leader"), - "now": datetime.now(timezone.utc), + "now": self._naive_utcnow(), "id": user["id"], }, ) @@ -133,7 +138,7 @@ class AuthServiceImpl(IAuthService): "ou_id": OuId or "", "ou_name": OuName or "", "is_leader": bool(IsLeader), - "now": datetime.now(timezone.utc), + "now": self._naive_utcnow(), }, ) user_id = created.scalar_one() diff --git a/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py new file mode 100644 index 0000000..1976111 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/entryModuleAdminServiceImpl.py @@ -0,0 +1,280 @@ +"""入口模块管理服务实现。""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from sqlalchemy import text + +from fastapi_admin.config import OSS_BASE_URL, OSS_BUCKET +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.domian.Dto.entryModuleDto import EntryModuleCreateDTO, EntryModuleUpdateDTO +from fastapi_modules.fastapi_leaudit.domian.vo.entryModuleAdminVo import ( + EntryModuleAreaVO, + EntryModuleImageUploadVO, + EntryModuleListVO, + EntryModuleVO, +) +from fastapi_modules.fastapi_leaudit.services.entryModuleAdminService import IEntryModuleAdminService +from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl + + +class EntryModuleAdminServiceImpl(IEntryModuleAdminService): + """入口模块管理服务实现。""" + + def __init__(self) -> None: + self.OssService = OssServiceImpl() + + async def ListModules(self, Name: str | None, Area: str | None, Page: int, PageSize: int) -> EntryModuleListVO: + """分页查询入口模块。""" + offset = max(Page - 1, 0) * PageSize + filters = ["deleted_at IS NULL"] + params: dict[str, object] = {"limit": PageSize, "offset": offset} + + if Name: + filters.append("name ILIKE :name") + params["name"] = f"%{Name.strip()}%" + + if Area: + filters.append( + "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(areas, '[]'::jsonb)) AS area_item WHERE area_item->>'area' = :area)" + ) + params["area"] = Area.strip() + + whereClause = " AND ".join(filters) + + async with GetAsyncSession() as Session: + total = int( + ( + await Session.execute( + text(f"SELECT COUNT(*) FROM leaudit_entry_modules WHERE {whereClause}"), + params, + ) + ).scalar_one() + ) + + rows = ( + await Session.execute( + text( + f""" + SELECT id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at + FROM leaudit_entry_modules + WHERE {whereClause} + ORDER BY sort_order ASC, id ASC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + + return EntryModuleListVO( + total=total, + page=Page, + page_size=PageSize, + items=[self._toModuleVo(row) for row in rows], + ) + + async def GetModule(self, ModuleId: int) -> EntryModuleVO: + """获取入口模块详情。""" + row = await self._getModuleRow(ModuleId) + return self._toModuleVo(row) + + async def CreateModule(self, Body: EntryModuleCreateDTO) -> EntryModuleVO: + """创建入口模块。""" + async with GetAsyncSession() as Session: + try: + row = ( + await Session.execute( + text( + """ + INSERT INTO leaudit_entry_modules ( + name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at, deleted_at + ) VALUES ( + :name, :description, :route_path, :icon_path, CAST(:areas AS jsonb), :sort_order, TRUE, NOW(), NOW(), NULL + ) + RETURNING id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at + """ + ), + { + "name": Body.name.strip(), + "description": (Body.description or "").strip() or None, + "route_path": (Body.path or "").strip() or None, + "icon_path": None, + "areas": self._areasJson(Body.areas), + "sort_order": await self._nextSortOrder(Session), + }, + ) + ).mappings().one() + await Session.commit() + except Exception as exc: + await Session.rollback() + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"创建入口模块失败: {exc}") from exc + return self._toModuleVo(row) + + async def UpdateModule(self, ModuleId: int, Body: EntryModuleUpdateDTO) -> EntryModuleVO: + """更新入口模块。""" + current = await self._getModuleRow(ModuleId) + async with GetAsyncSession() as Session: + row = ( + await Session.execute( + text( + """ + UPDATE leaudit_entry_modules + SET + name = :name, + description = :description, + path = :route_path, + areas = CAST(:areas AS jsonb), + updated_at = NOW() + WHERE id = :module_id + AND deleted_at IS NULL + RETURNING id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at + """ + ), + { + "module_id": ModuleId, + "name": Body.name.strip() if Body.name is not None else current["name"], + "description": (Body.description.strip() if Body.description is not None else current["description"]), + "route_path": (Body.path.strip() if Body.path is not None else current["path"]), + "areas": self._areasJson(Body.areas) if Body.areas is not None else self._areasJson(current["areas"]), + }, + ) + ).mappings().first() + if not row: + await Session.rollback() + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "入口模块不存在") + await Session.commit() + return self._toModuleVo(row) + + async def DeleteModule(self, ModuleId: int) -> None: + """删除入口模块。""" + async with GetAsyncSession() as Session: + await Session.execute( + text( + """ + UPDATE leaudit_entry_modules + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = :module_id AND deleted_at IS NULL + """ + ), + {"module_id": ModuleId}, + ) + await Session.commit() + + async def UploadModuleImage(self, ModuleId: int, FileName: str, ContentType: str, Content: bytes) -> EntryModuleImageUploadVO: + """上传入口模块图标。""" + module = await self._getModuleRow(ModuleId) + suffix = Path(FileName).suffix.lower() or ".png" + objectKey = f"documents/mz/static/img/entry_module_{ModuleId}{suffix}" + await self.OssService.UploadBytes(ObjectKey=objectKey, Content=Content, ContentType=ContentType or "application/octet-stream") + + async with GetAsyncSession() as Session: + await Session.execute( + text( + """ + UPDATE leaudit_entry_modules + SET icon_path = :icon_path, updated_at = NOW() + WHERE id = :module_id AND deleted_at IS NULL + """ + ), + {"module_id": ModuleId, "icon_path": objectKey}, + ) + await Session.commit() + + return EntryModuleImageUploadVO( + module_id=ModuleId, + path=objectKey, + url=f"{OSS_BASE_URL.rstrip('/')}/{OSS_BUCKET}/{objectKey}", + message=f"入口模块 {module['name']} 图标上传成功", + ) + + async def _getModuleRow(self, ModuleId: int): + """查询入口模块原始记录。""" + async with GetAsyncSession() as Session: + row = ( + await Session.execute( + text( + """ + SELECT id, name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at + FROM leaudit_entry_modules + WHERE id = :module_id AND deleted_at IS NULL + """ + ), + {"module_id": ModuleId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "入口模块不存在") + return row + + async def _nextSortOrder(self, Session) -> int: + """获取下一个排序号。""" + maxSort = ( + await Session.execute(text("SELECT COALESCE(MAX(sort_order), 0) FROM leaudit_entry_modules WHERE deleted_at IS NULL")) + ).scalar_one() + return int(maxSort or 0) + 10 + + def _toModuleVo(self, Row) -> EntryModuleVO: + """把数据库记录转换为 VO。""" + rawAreas = Row.get("areas") or [] + areas = [ + EntryModuleAreaVO( + area=str(item.get("area") or ""), + enabled=bool(item.get("enabled", False)), + sort_order=int(item.get("sort_order", 0)), + ) + for item in rawAreas + if isinstance(item, dict) and item.get("area") + ] + areas.sort(key=lambda item: (item.sort_order, item.area)) + return EntryModuleVO( + id=int(Row["id"]), + name=str(Row["name"] or ""), + description=Row.get("description"), + path=Row.get("icon_path"), + route_path=Row.get("path"), + sort_order=int(Row.get("sort_order") or 0), + is_enabled=bool(Row.get("is_enabled", True)), + areas=areas, + created_at=self._toIso(Row.get("created_at")), + updated_at=self._toIso(Row.get("updated_at")), + ) + + def _areasJson(self, Areas) -> str: + """序列化地区配置。""" + import json + + if not Areas: + return "[]" + + normalized: list[dict[str, object]] = [] + for index, item in enumerate(Areas, start=1): + if hasattr(item, "model_dump"): + payload = item.model_dump() + elif isinstance(item, dict): + payload = item + else: + continue + if not payload.get("area"): + continue + normalized.append( + { + "area": str(payload["area"]), + "enabled": bool(payload.get("enabled", True)), + "sort_order": int(payload.get("sort_order", index)), + } + ) + return json.dumps(normalized, ensure_ascii=False) + + def _toIso(self, Value) -> str | None: + """时间转 ISO 字符串。""" + if Value is None: + return None + if isinstance(Value, datetime): + return Value.isoformat() + return str(Value) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py new file mode 100644 index 0000000..30e0556 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/homeServiceImpl.py @@ -0,0 +1,225 @@ +"""首页入口服务实现。""" + +from __future__ import annotations + +from sqlalchemy import text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeEntryAreaVO, HomeEntryDocumentTypeVO, HomeEntryModuleVO +from fastapi_modules.fastapi_leaudit.services.homeService import IHomeService +from fastapi_modules.fastapi_leaudit.services.impl.rbacServiceImpl import RbacServiceImpl + + +class HomeServiceImpl(IHomeService): + """首页入口服务实现。""" + + _MINIMAL_ENABLED_TARGETS: tuple[str, ...] = ( + "/home", + "/files/upload", + "/documents", + "/chat-with-llm/chat", + ) + + def __init__(self) -> None: + self.RbacService = RbacServiceImpl() + + async def GetEntryModules(self, UserId: int) -> list[HomeEntryModuleVO]: + """获取当前用户可见的首页入口模块。""" + allowedPaths = await self._loadAllowedPaths(UserId=UserId) + async with GetAsyncSession() as Session: + userResult = await Session.execute( + text( + """ + SELECT + u.id, + COALESCE(u.area, '') AS area, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS bypass_area + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.id = :user_id + AND u.deleted_at IS NULL + AND u.status = 0 + GROUP BY u.id, u.area + """ + ), + {"user_id": UserId}, + ) + userRow = userResult.mappings().first() + if not userRow: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在或已停用") + + result = await Session.execute( + text( + """ + WITH user_roles AS ( + SELECT ur.role_id + FROM user_role ur + WHERE ur.user_id = :user_id + ) + SELECT + em.id, + em.name, + em.description, + em.path, + em.icon_path, + em.areas, + em.sort_order, + COALESCE( + json_agg( + json_build_object( + 'id', dt.id, + 'name', dt.name, + 'code', dt.code + ) + ORDER BY dt.sort_order ASC, dt.id ASC + ) FILTER ( + WHERE dt.id IS NOT NULL + AND dt.deleted_at IS NULL + AND dt.is_enabled = TRUE + ), + '[]'::json + ) AS document_types + FROM leaudit_entry_modules em + LEFT JOIN leaudit_document_types dt + ON dt.entry_module_id = em.id + WHERE em.deleted_at IS NULL + AND em.is_enabled = TRUE + AND ( + :bypass_area = TRUE + OR COALESCE(:user_area, '') = '' + OR em.areas IS NULL + OR jsonb_typeof(em.areas) <> 'array' + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(em.areas) AS area_item + WHERE area_item->>'area' = :user_area + AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE + ) + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(em.areas) AS area_item + WHERE area_item->>'area' = 'default' + AND COALESCE((area_item->>'enabled')::boolean, FALSE) = TRUE + ) + ) + GROUP BY + em.id, + em.name, + em.description, + em.path, + em.icon_path, + em.areas, + em.sort_order + ORDER BY em.sort_order ASC, em.id ASC + """ + ), + { + "user_id": UserId, + "user_area": str(userRow["area"] or ""), + "bypass_area": bool(userRow["bypass_area"]), + }, + ) + + modules: list[HomeEntryModuleVO] = [] + for row in result.mappings().all(): + areas: list[HomeEntryAreaVO] = [] + rawAreas = row["areas"] + if isinstance(rawAreas, list): + for areaItem in rawAreas: + if isinstance(areaItem, dict) and areaItem.get("area"): + areas.append( + HomeEntryAreaVO( + area=str(areaItem["area"]), + enabled=bool(areaItem.get("enabled", False)), + sortOrder=int(areaItem.get("sort_order", 0)), + ) + ) + + documentTypes: list[HomeEntryDocumentTypeVO] = [] + rawDocumentTypes = row["document_types"] + if isinstance(rawDocumentTypes, list): + for documentType in rawDocumentTypes: + if isinstance(documentType, dict) and documentType.get("id") is not None: + documentTypes.append( + HomeEntryDocumentTypeVO( + id=int(documentType["id"]), + name=str(documentType["name"]), + code=documentType.get("code"), + ) + ) + + targetPath = self._normalizeTargetPath( + RawPath=str(row["path"] or ""), + HasDocumentTypes=len(documentTypes) > 0, + ) + if not targetPath: + continue + + if not self._isAllowedTargetPath(targetPath, allowedPaths): + continue + + requiresDocumentTypes = targetPath not in {"/chat-with-llm/chat"} + + modules.append( + HomeEntryModuleVO( + id=int(row["id"]), + name=str(row["name"]), + description=row["description"], + targetPath=targetPath, + routePath=targetPath, + iconPath=row["icon_path"], + sortOrder=int(row["sort_order"] or 0), + requiresDocumentTypes=requiresDocumentTypes, + areas=areas, + documentTypes=documentTypes, + ) + ) + + return modules + + async def _loadAllowedPaths(self, UserId: int) -> set[str]: + """加载当前用户在首页可点击的目标路径集合。""" + routesVo = await self.RbacService.GetCurrentUserRoutes(UserId=UserId) + allowedPaths: set[str] = set() + + def collect(items) -> None: + for item in items: + allowedPaths.add(str(item.route_path or "")) + if item.children: + collect(item.children) + + collect(routesVo.routes) + return {path for path in allowedPaths if path} + + def _isAllowedTargetPath(self, TargetPath: str, AllowedPaths: set[str]) -> bool: + """判断首页目标路径是否被当前用户路由树覆盖。""" + if TargetPath in AllowedPaths: + return True + + return any(TargetPath.startswith(f"{path}/") for path in AllowedPaths) + + def _normalizeTargetPath(self, RawPath: str, HasDocumentTypes: bool) -> str | None: + """将首页入口跳转归一到当前最小可用路径集合。""" + if HasDocumentTypes and (not RawPath or RawPath == "/home" or not RawPath.startswith("/")): + return "/files/upload" + + if RawPath == "/contract-template/search" and HasDocumentTypes: + return "/files/upload" + + if RawPath == "/cross-checking": + return None + + if any( + RawPath == enabledPath or RawPath.startswith(f"{enabledPath}/") + for enabledPath in self._MINIMAL_ENABLED_TARGETS + ): + return RawPath + + if HasDocumentTypes: + return "/files/upload" + + return None diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py new file mode 100644 index 0000000..a86c964 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -0,0 +1,818 @@ +"""RBAC 管理服务实现。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import ( + RoleCreateDTO, + RolePermissionsBatchDTO, + RoleRoutesUpdateDTO, + RoleUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( + RoleListVO, + RolePermissionsVO, + RoleRoutesVO, + RoleRouteUpdateResultVO, + RoleVO, + RoutePermissionsVO, + RoutePermissionVO, + RouteVO, + UserListVO, + UserRoleVO, + UserRolesVO, + UserVO, +) +from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdminService + + +class RbacAdminServiceImpl(IRbacAdminService): + """RBAC 管理服务实现。""" + + _MANAGEABLE_ROUTE_BLUEPRINTS: list[dict[str, Any]] = [ + { + "route_path": "/home", + "route_name": "home", + "component": "home", + "route_title": "系统概览", + "icon": "ri-home-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "overview"}, + }, + { + "route_path": "/chat-with-llm", + "route_name": "chat-with-llm", + "component": "chat-with-llm", + "route_title": "AI对话", + "icon": "ri-chat-smile-2-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "assistant"}, + }, + { + "route_path": "/files", + "route_name": "file-management", + "component": "files", + "route_title": "文件管理", + "icon": "ri-folder-line", + "sort_order": 3, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + }, + { + "route_path": "/files/upload", + "route_name": "file-upload", + "component": "files.upload", + "route_title": "文件上传", + "icon": "ri-upload-cloud-line", + "sort_order": 1, + "parent_path": "/files", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + }, + { + "route_path": "/documents", + "route_name": "documents", + "component": "documents", + "route_title": "文档列表", + "icon": "ri-file-list-3-line", + "sort_order": 2, + "parent_path": "/files", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + }, + { + "route_path": "/settings", + "route_name": "system-settings", + "component": "settings", + "route_title": "系统设置", + "icon": "ri-settings-4-line", + "sort_order": 90, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + }, + { + "route_path": "/entry-modules", + "route_name": "entry-modules", + "component": "entry-modules", + "route_title": "入口模块管理", + "icon": "ri-apps-2-line", + "sort_order": 1, + "parent_path": "/settings", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + }, + { + "route_path": "/role-permissions", + "route_name": "role-permissions", + "component": "role-permissions", + "route_title": "角色权限管理", + "icon": "ri-shield-user-line", + "sort_order": 2, + "parent_path": "/settings", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + }, + ] + + _MANAGEABLE_PERMISSION_BLUEPRINTS: list[dict[str, Any]] = [ + {"permission_key": "entry_module:list:read", "display_name": "入口模块列表", "module": "entry_module", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/entry-modules", "route_path": "/entry-modules"}, + {"permission_key": "entry_module:detail:read", "display_name": "入口模块详情", "module": "entry_module", "resource": "detail", "action": "read", "api_method": "GET", "api_path": "/api/v3/entry-modules/{id}", "route_path": "/entry-modules"}, + {"permission_key": "entry_module:create:write", "display_name": "创建入口模块", "module": "entry_module", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/entry-modules", "route_path": "/entry-modules"}, + {"permission_key": "entry_module:update:write", "display_name": "更新入口模块", "module": "entry_module", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/entry-modules/{id}", "route_path": "/entry-modules"}, + {"permission_key": "entry_module:delete:delete", "display_name": "删除入口模块", "module": "entry_module", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/entry-modules/{id}", "route_path": "/entry-modules"}, + {"permission_key": "entry_module:image:write", "display_name": "上传入口模块图标", "module": "entry_module", "resource": "image", "action": "write", "api_method": "POST", "api_path": "/api/v3/entry-modules/{id}/image", "route_path": "/entry-modules"}, + {"permission_key": "rbac:roles:read", "display_name": "角色列表", "module": "rbac", "resource": "roles", "action": "read", "api_method": "GET", "api_path": "/api/v3/rbac/roles", "route_path": "/role-permissions"}, + {"permission_key": "rbac:roles:create", "display_name": "创建角色", "module": "rbac", "resource": "roles", "action": "create", "api_method": "POST", "api_path": "/api/v3/rbac/roles", "route_path": "/role-permissions"}, + {"permission_key": "rbac:roles:update", "display_name": "更新角色", "module": "rbac", "resource": "roles", "action": "update", "api_method": "PUT", "api_path": "/api/v3/rbac/roles/{role_id}", "route_path": "/role-permissions"}, + {"permission_key": "rbac:roles:delete", "display_name": "删除角色", "module": "rbac", "resource": "roles", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/rbac/roles/{role_id}", "route_path": "/role-permissions"}, + {"permission_key": "rbac:users:read", "display_name": "用户列表", "module": "rbac", "resource": "users", "action": "read", "api_method": "GET", "api_path": "/api/v3/rbac/users", "route_path": "/role-permissions"}, + {"permission_key": "rbac:user_roles:write", "display_name": "分配用户角色", "module": "rbac", "resource": "user_roles", "action": "write", "api_method": "POST", "api_path": "/api/v3/rbac/users/{user_id}/roles", "route_path": "/role-permissions"}, + {"permission_key": "rbac:role_routes:write", "display_name": "配置角色菜单", "module": "rbac", "resource": "role_routes", "action": "write", "api_method": "PUT", "api_path": "/api/rbac/roles/{role_id}/routes", "route_path": "/role-permissions"}, + {"permission_key": "rbac:role_permissions:write", "display_name": "配置角色权限", "module": "rbac", "resource": "role_permissions", "action": "write", "api_method": "POST", "api_path": "/api/v3/rbac/role-permissions", "route_path": "/role-permissions"}, + ] + + async def ListRoles(self, CurrentUserId: int, Page: int, PageSize: int, RoleKey: str | None, RoleName: str | None, IncludeSystem: bool) -> RoleListVO: + """查询角色列表。""" + await self._assertManagePermission(CurrentUserId) + offset = max(Page - 1, 0) * PageSize + filters = ["1=1"] + params: dict[str, object] = {"limit": PageSize, "offset": offset} + if RoleKey: + filters.append("role_key ILIKE :role_key") + params["role_key"] = f"%{RoleKey.strip()}%" + if RoleName: + filters.append("role_name ILIKE :role_name") + params["role_name"] = f"%{RoleName.strip()}%" + if not IncludeSystem: + filters.append("is_system_role = FALSE") + whereClause = " AND ".join(filters) + + async with GetAsyncSession() as Session: + total = int((await Session.execute(text(f"SELECT COUNT(*) FROM roles WHERE {whereClause}"), params)).scalar_one()) + rows = ( + await Session.execute( + text( + f""" + SELECT id, role_key, role_name, data_scope, description, parent_role_id, priority, is_system_role, created_at, updated_at + FROM roles + WHERE {whereClause} + ORDER BY priority DESC, id ASC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + return RoleListVO(total=total, page=Page, page_size=PageSize, items=[self._toRoleVo(row) for row in rows]) + + async def CreateRole(self, CurrentUserId: int, Body: RoleCreateDTO) -> RoleVO: + """创建角色。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + row = ( + await Session.execute( + text( + """ + INSERT INTO roles (role_key, role_name, data_scope, description, priority, is_system_role, metadata, created_at, updated_at) + VALUES (:role_key, :role_name, :data_scope, :description, 0, FALSE, CAST(:metadata AS jsonb), NOW(), NOW()) + RETURNING id, role_key, role_name, data_scope, description, parent_role_id, priority, is_system_role, created_at, updated_at + """ + ), + { + "role_key": Body.role_key.strip(), + "role_name": Body.role_name.strip(), + "data_scope": Body.data_scope, + "description": (Body.description or "").strip(), + "metadata": self._jsonDump(Body.metadata or {}), + }, + ) + ).mappings().one() + await Session.commit() + return self._toRoleVo(row) + + async def UpdateRole(self, CurrentUserId: int, RoleId: int, Body: RoleUpdateDTO) -> RoleVO: + """更新角色。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + current = await self._getRoleRow(Session, RoleId) + row = ( + await Session.execute( + text( + """ + UPDATE roles + SET role_name = :role_name, + description = :description, + data_scope = :data_scope, + priority = :priority, + parent_role_id = :parent_role_id, + updated_at = NOW() + WHERE id = :role_id + RETURNING id, role_key, role_name, data_scope, description, parent_role_id, priority, is_system_role, created_at, updated_at + """ + ), + { + "role_id": RoleId, + "role_name": Body.role_name if Body.role_name is not None else current["role_name"], + "description": Body.description if Body.description is not None else current["description"], + "data_scope": Body.data_scope if Body.data_scope is not None else current["data_scope"], + "priority": Body.priority if Body.priority is not None else current["priority"], + "parent_role_id": Body.parent_role_id if Body.parent_role_id is not None else current["parent_role_id"], + }, + ) + ).mappings().one() + await Session.commit() + return self._toRoleVo(row) + + async def DeleteRole(self, CurrentUserId: int, RoleId: int, Force: bool) -> None: + """删除角色。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + role = await self._getRoleRow(Session, RoleId) + if role["is_system_role"]: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "系统内置角色不允许删除") + + userCount = int((await Session.execute(text("SELECT COUNT(*) FROM user_role WHERE role_id = :role_id"), {"role_id": RoleId})).scalar_one()) + if userCount > 0 and not Force: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "角色仍绑定用户,请传 force=true 后重试") + + if Force: + await Session.execute(text("DELETE FROM user_role WHERE role_id = :role_id"), {"role_id": RoleId}) + await Session.execute(text("DELETE FROM roles WHERE id = :role_id"), {"role_id": RoleId}) + await Session.commit() + + async def ListUsers(self, CurrentUserId: int, Page: int, PageSize: int, Area: str | None, NickName: str | None) -> UserListVO: + """查询用户列表。""" + currentUser = await self._getCurrentUserContext(CurrentUserId) + offset = max(Page - 1, 0) * PageSize + filters = ["u.deleted_at IS NULL", "u.status = 0"] + params: dict[str, object] = {"limit": PageSize, "offset": offset} + if NickName: + filters.append("u.nick_name ILIKE :nick_name") + params["nick_name"] = f"%{NickName.strip()}%" + if Area: + filters.append("u.area = :query_area") + params["query_area"] = Area.strip() + elif not currentUser["is_global"]: + filters.append("COALESCE(u.area, '') = :user_area") + params["user_area"] = currentUser["area"] + whereClause = " AND ".join(filters) + + async with GetAsyncSession() as Session: + total = int((await Session.execute(text(f"SELECT COUNT(*) FROM sso_users u WHERE {whereClause}"), params)).scalar_one()) + rows = ( + await Session.execute( + text( + f""" + SELECT + u.id, u.username, u.nick_name, u.phone_number, u.email, u.area, u.ou_name, u.ou_id, u.status, + u.is_leader, u.tenant_name, u.dep_name, + COALESCE( + json_agg( + DISTINCT jsonb_build_object('role_id', r.id, 'role_key', r.role_key, 'role_name', r.role_name) + ) FILTER (WHERE r.id IS NOT NULL), + '[]'::json + ) AS roles + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE {whereClause} + GROUP BY u.id + ORDER BY u.created_at DESC, u.id DESC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + return UserListVO(total=total, page=Page, page_size=PageSize, items=[self._toUserVo(row) for row in rows]) + + async def ListRoleUsers(self, CurrentUserId: int, RoleId: int, Page: int, PageSize: int, Area: str | None, UserName: str | None) -> UserListVO: + """查询指定角色下的用户列表。""" + currentUser = await self._getCurrentUserContext(CurrentUserId) + offset = max(Page - 1, 0) * PageSize + filters = ["u.deleted_at IS NULL", "u.status = 0", "ur.role_id = :role_id"] + params: dict[str, object] = {"role_id": RoleId, "limit": PageSize, "offset": offset} + if UserName: + filters.append("(u.username ILIKE :user_name OR u.nick_name ILIKE :user_name)") + params["user_name"] = f"%{UserName.strip()}%" + if Area: + filters.append("u.area = :query_area") + params["query_area"] = Area.strip() + elif not currentUser["is_global"]: + filters.append("COALESCE(u.area, '') = :user_area") + params["user_area"] = currentUser["area"] + whereClause = " AND ".join(filters) + + async with GetAsyncSession() as Session: + total = int( + ( + await Session.execute( + text( + f""" + SELECT COUNT(DISTINCT u.id) + FROM sso_users u + JOIN user_role ur ON ur.user_id = u.id + WHERE {whereClause} + """ + ), + params, + ) + ).scalar_one() + ) + rows = ( + await Session.execute( + text( + f""" + SELECT + u.id, u.username, u.nick_name, u.phone_number, u.email, u.area, u.ou_name, u.ou_id, u.status, + u.is_leader, u.tenant_name, u.dep_name, + COALESCE( + json_agg( + DISTINCT jsonb_build_object('role_id', r.id, 'role_key', r.role_key, 'role_name', r.role_name) + ) FILTER (WHERE r.id IS NOT NULL), + '[]'::json + ) AS roles + FROM sso_users u + JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN user_role all_ur ON all_ur.user_id = u.id + LEFT JOIN roles r ON r.id = all_ur.role_id + WHERE {whereClause} + GROUP BY u.id + ORDER BY u.created_at DESC, u.id DESC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + return UserListVO(total=total, page=Page, page_size=PageSize, items=[self._toUserVo(row) for row in rows]) + + async def AssignUserRoles(self, CurrentUserId: int, UserId: int, RoleIds: list[int]) -> UserRolesVO: + """为用户分配角色。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + await Session.execute(text("DELETE FROM user_role WHERE user_id = :user_id"), {"user_id": UserId}) + for roleId in sorted(set(RoleIds)): + await Session.execute( + text( + "INSERT INTO user_role (user_id, role_id, created_at, updated_at) VALUES (:user_id, :role_id, NOW(), NOW())" + ), + {"user_id": UserId, "role_id": roleId}, + ) + await Session.commit() + return await self.GetUserRoles(CurrentUserId, UserId) + + async def RevokeUserRole(self, CurrentUserId: int, UserId: int, RoleId: int) -> None: + """移除用户角色。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + await Session.execute(text("DELETE FROM user_role WHERE user_id = :user_id AND role_id = :role_id"), {"user_id": UserId, "role_id": RoleId}) + await Session.commit() + + async def GetUserRoles(self, CurrentUserId: int, UserId: int) -> UserRolesVO: + """查询用户角色。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + userRow = ( + await Session.execute(text("SELECT id, username FROM sso_users WHERE id = :user_id"), {"user_id": UserId}) + ).mappings().first() + if not userRow: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "用户不存在") + roleRows = ( + await Session.execute( + text( + """ + SELECT r.id, r.role_key, r.role_name, r.data_scope, r.description, r.parent_role_id, r.priority, r.is_system_role, r.created_at, r.updated_at + FROM user_role ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = :user_id + ORDER BY r.priority DESC, r.id ASC + """ + ), + {"user_id": UserId}, + ) + ).mappings().all() + return UserRolesVO(user_id=UserId, username=str(userRow["username"] or ""), roles=[self._toRoleVo(row) for row in roleRows]) + + async def ListAllRoutes(self, CurrentUserId: int, Format: str, IncludeHidden: bool) -> list[RouteVO]: + """查询全部可管理路由。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + routeMap = await self._ensureAdminSeeds(Session) + rows = ( + await Session.execute( + text( + """ + SELECT id, route_path, route_name, component, parent_id, route_title, icon, sort_order, is_hidden, is_cache, status + FROM sys_routes + WHERE deleted_at IS NULL AND route_path = ANY(:paths) + ORDER BY sort_order ASC, id ASC + """ + ).bindparams(paths=list(routeMap.keys())), + ) + ).mappings().all() + permissionMap = await self._loadPermissionsByRoute(Session, [int(row["id"]) for row in rows]) + routeVos = [self._toRouteVo(row, permissionMap.get(int(row["id"]), []), False) for row in rows if IncludeHidden or not bool(row["is_hidden"])] + return routeVos if Format == "flat" else self._buildRouteTree(routeVos) + + async def GetRoleRoutes(self, CurrentUserId: int, RoleId: int) -> RoleRoutesVO: + """查询角色路由授权。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + routeMap = await self._ensureAdminSeeds(Session) + rows = ( + await Session.execute( + text( + """ + SELECT + sr.id, sr.route_path, sr.route_name, sr.component, sr.parent_id, sr.route_title, sr.icon, + sr.sort_order, sr.is_hidden, sr.is_cache, sr.status, + COALESCE(rr.status, 0) AS enabled + FROM sys_routes sr + LEFT JOIN role_route rr ON rr.route_id = sr.id AND rr.role_id = :role_id + WHERE sr.deleted_at IS NULL AND sr.route_path = ANY(:paths) + ORDER BY sr.sort_order ASC, sr.id ASC + """ + ).bindparams(paths=list(routeMap.keys())), + {"role_id": RoleId}, + ) + ).mappings().all() + permissionMap = await self._loadPermissionsByRoute(Session, [int(row["id"]) for row in rows]) + routeVos = [self._toRouteVo(row, permissionMap.get(int(row["id"]), []), bool(row["enabled"])) for row in rows] + return RoleRoutesVO(role_id=RoleId, routes=self._buildRouteTree(routeVos)) + + async def UpdateRoleRoutes(self, CurrentUserId: int, RoleId: int, Body: RoleRoutesUpdateDTO) -> RoleRouteUpdateResultVO: + """更新角色路由授权。""" + await self._assertManagePermission(CurrentUserId) + routeIds = sorted(set(Body.route_ids)) + async with GetAsyncSession() as Session: + await self._ensureAdminSeeds(Session) + allRouteIds = [row[0] for row in (await Session.execute(text("SELECT id FROM sys_routes WHERE deleted_at IS NULL AND route_path = ANY(:paths)").bindparams(paths=[item["route_path"] for item in self._MANAGEABLE_ROUTE_BLUEPRINTS]))).fetchall()] + existingRows = ( + await Session.execute(text("SELECT route_id, status FROM role_route WHERE role_id = :role_id AND route_id = ANY(:route_ids)").bindparams(route_ids=allRouteIds), {"role_id": RoleId}) + ).fetchall() + existingMap = {int(routeId): int(status) for routeId, status in existingRows} + insertedCount = 0 + for routeId in allRouteIds: + if routeId in routeIds: + if routeId in existingMap: + await Session.execute( + text("UPDATE role_route SET status = 1, permission = :permission, updated_at = NOW() WHERE role_id = :role_id AND route_id = :route_id"), + {"role_id": RoleId, "route_id": routeId, "permission": Body.permission}, + ) + else: + await Session.execute( + text("INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) VALUES (:role_id, :route_id, :permission, 1, NOW(), NOW())"), + {"role_id": RoleId, "route_id": routeId, "permission": Body.permission}, + ) + insertedCount += 1 + elif routeId in existingMap and existingMap[routeId] != 0: + await Session.execute( + text("UPDATE role_route SET status = 0, updated_at = NOW() WHERE role_id = :role_id AND route_id = :route_id"), + {"role_id": RoleId, "route_id": routeId}, + ) + await Session.commit() + return RoleRouteUpdateResultVO(role_id=RoleId, enabled_count=len(routeIds), disabled_count=max(len(allRouteIds) - len(routeIds), 0), inserted_count=insertedCount, route_ids=routeIds) + + async def GetRolePermissions(self, CurrentUserId: int, RoleId: int) -> RolePermissionsVO: + """查询角色权限授权。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + await self._ensureAdminSeeds(Session) + rows = ( + await Session.execute( + text( + """ + SELECT rp.id, rp.permission_id, p.permission_key, p.display_name, rp.grant_type, rp.data_scope + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = :role_id + ORDER BY p.sort_order ASC, p.id ASC + """ + ), + {"role_id": RoleId}, + ) + ).mappings().all() + return RolePermissionsVO(role_id=RoleId, permissions=[self._toRolePermissionVo(row) for row in rows]) + + async def SaveRolePermissions(self, CurrentUserId: int, Body: RolePermissionsBatchDTO) -> RolePermissionsVO: + """保存角色权限授权。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + await self._ensureAdminSeeds(Session) + permissionIds = [item.permission_id for item in Body.permissions] + if Body.replace: + await Session.execute(text("DELETE FROM role_permissions WHERE role_id = :role_id"), {"role_id": Body.role_id}) + for item in Body.permissions: + await Session.execute( + text( + """ + INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at) + VALUES (:role_id, :permission_id, :grant_type, :data_scope, NOW(), NOW()) + ON CONFLICT (role_id, permission_id) + DO UPDATE SET grant_type = EXCLUDED.grant_type, data_scope = EXCLUDED.data_scope, updated_at = NOW() + """ + ), + { + "role_id": Body.role_id, + "permission_id": item.permission_id, + "grant_type": item.grant_type, + "data_scope": item.data_scope, + }, + ) + await Session.commit() + return await self.GetRolePermissions(CurrentUserId, Body.role_id) + + async def GetRoutePermissions(self, CurrentUserId: int, RouteId: int) -> RoutePermissionsVO: + """查询路由关联权限定义。""" + await self._assertManagePermission(CurrentUserId) + async with GetAsyncSession() as Session: + await self._ensureAdminSeeds(Session) + routeRow = ( + await Session.execute(text("SELECT id, route_path, route_title FROM sys_routes WHERE id = :route_id"), {"route_id": RouteId}) + ).mappings().first() + if not routeRow: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "路由不存在") + permissionMap = await self._loadPermissionsByRoute(Session, [RouteId]) + return RoutePermissionsVO( + route_id=RouteId, + route_path=str(routeRow["route_path"] or ""), + route_title=str(routeRow["route_title"] or ""), + permissions=permissionMap.get(RouteId, []), + ) + + async def _assertManagePermission(self, CurrentUserId: int) -> None: + """校验当前用户是否具备管理能力。""" + context = await self._getCurrentUserContext(CurrentUserId) + if not context["can_manage"]: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有系统设置管理权限") + + async def _getCurrentUserContext(self, CurrentUserId: int) -> dict[str, Any]: + """加载当前用户上下文。""" + async with GetAsyncSession() as Session: + row = ( + await Session.execute( + text( + """ + SELECT + u.id, + COALESCE(u.area, '') AS area, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.id = :user_id + GROUP BY u.id, u.area + """ + ), + {"user_id": CurrentUserId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") + return {"area": str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"])} + + async def _ensureAdminSeeds(self, Session) -> dict[str, int]: + """确保系统设置所需路由和权限定义已存在。""" + routeIdsByPath: dict[str, int] = {} + for blueprint in self._MANAGEABLE_ROUTE_BLUEPRINTS: + parentId = routeIdsByPath.get(str(blueprint.get("parent_path"))) if blueprint.get("parent_path") else None + row = ( + await Session.execute( + text( + """ + INSERT INTO sys_routes (route_path, route_name, component, parent_id, route_title, icon, sort_order, is_hidden, is_cache, meta, status, created_at, updated_at, deleted_at) + VALUES (:route_path, :route_name, :component, :parent_id, :route_title, :icon, :sort_order, :is_hidden, :is_cache, CAST(:meta AS jsonb), 0, NOW(), NOW(), NULL) + ON CONFLICT (route_path) WHERE deleted_at IS NULL + DO UPDATE SET route_name = EXCLUDED.route_name, component = EXCLUDED.component, parent_id = EXCLUDED.parent_id, + route_title = EXCLUDED.route_title, icon = EXCLUDED.icon, sort_order = EXCLUDED.sort_order, + is_hidden = EXCLUDED.is_hidden, is_cache = EXCLUDED.is_cache, meta = EXCLUDED.meta, status = 0, updated_at = NOW() + RETURNING id + """ + ), + { + "route_path": blueprint["route_path"], + "route_name": blueprint["route_name"], + "component": blueprint.get("component"), + "parent_id": parentId, + "route_title": blueprint["route_title"], + "icon": blueprint.get("icon"), + "sort_order": blueprint.get("sort_order", 0), + "is_hidden": bool(blueprint.get("is_hidden", False)), + "is_cache": bool(blueprint.get("is_cache", True)), + "meta": self._jsonDump(blueprint.get("meta") or {}), + }, + ) + ).scalar_one() + routeIdsByPath[str(blueprint["route_path"])] = int(row) + + for blueprint in self._MANAGEABLE_PERMISSION_BLUEPRINTS: + routeId = routeIdsByPath.get(str(blueprint["route_path"])) + await Session.execute( + text( + """ + INSERT INTO permissions ( + permission_key, module, resource, action, description, display_name, permission_type, + is_system, metadata, created_at, updated_at, parent_id, sort_order, route_id, api_path, api_method, related_routes + ) VALUES ( + :permission_key, :module, :resource, :action, :description, :display_name, 'API', TRUE, + NULL, NOW(), NOW(), NULL, 0, :route_id, :api_path, :api_method, NULL + ) + ON CONFLICT (permission_key) + DO UPDATE SET module = EXCLUDED.module, resource = EXCLUDED.resource, action = EXCLUDED.action, + display_name = EXCLUDED.display_name, route_id = EXCLUDED.route_id, + api_path = EXCLUDED.api_path, api_method = EXCLUDED.api_method, updated_at = NOW() + """ + ), + { + "permission_key": blueprint["permission_key"], + "module": blueprint["module"], + "resource": blueprint["resource"], + "action": blueprint["action"], + "description": blueprint["display_name"], + "display_name": blueprint["display_name"], + "route_id": routeId, + "api_path": blueprint["api_path"], + "api_method": blueprint["api_method"], + }, + ) + await Session.commit() + return routeIdsByPath + + async def _loadPermissionsByRoute(self, Session, RouteIds: list[int]) -> dict[int, list[RoutePermissionVO]]: + """加载路由关联权限定义。""" + if not RouteIds: + return {} + rows = ( + await Session.execute( + text( + """ + SELECT id, permission_key, display_name, api_method, api_path, route_id, related_routes + FROM permissions + WHERE route_id = ANY(:route_ids) + OR EXISTS ( + SELECT 1 + FROM unnest(COALESCE(related_routes, ARRAY[]::bigint[])) AS related_route_id + WHERE related_route_id = ANY(:route_ids) + ) + ORDER BY sort_order ASC, id ASC + """ + ).bindparams(route_ids=RouteIds), + ) + ).mappings().all() + output: dict[int, list[RoutePermissionVO]] = {int(routeId): [] for routeId in RouteIds} + for row in rows: + relatedRoutes = [int(item) for item in list(row.get("related_routes") or [])] + targetRouteIds = [] + if row.get("route_id") is not None: + targetRouteIds.append(int(row["route_id"])) + targetRouteIds.extend(relatedRoutes) + permissionVo = RoutePermissionVO( + id=int(row["id"]), + permission_key=str(row["permission_key"] or ""), + display_name=row.get("display_name"), + api_method=row.get("api_method"), + api_path=row.get("api_path"), + route_id=int(row["route_id"]) if row.get("route_id") is not None else None, + related_routes=relatedRoutes or None, + is_shared=bool(relatedRoutes), + ) + for routeId in targetRouteIds: + if routeId in output: + output[routeId].append(permissionVo) + return output + + async def _getRoleRow(self, Session, RoleId: int): + """查询角色原始记录。""" + row = ( + await Session.execute( + text( + "SELECT id, role_key, role_name, data_scope, description, parent_role_id, priority, is_system_role, created_at, updated_at FROM roles WHERE id = :role_id" + ), + {"role_id": RoleId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "角色不存在") + return row + + def _toRoleVo(self, Row) -> RoleVO: + """角色记录转 VO。""" + return RoleVO( + id=int(Row["id"]), + role_key=str(Row["role_key"] or ""), + role_name=str(Row["role_name"] or ""), + data_scope=str(Row["data_scope"] or "SELF"), + description=str(Row.get("description") or ""), + parent_role_id=int(Row["parent_role_id"]) if Row.get("parent_role_id") is not None else None, + priority=int(Row.get("priority") or 0), + is_system=bool(Row.get("is_system_role", False)), + created_at=self._toIso(Row.get("created_at")), + updated_at=self._toIso(Row.get("updated_at")), + ) + + def _toUserVo(self, Row) -> UserVO: + """用户记录转 VO。""" + roles = [] + for item in list(Row.get("roles") or []): + if isinstance(item, dict) and item.get("role_id") is not None: + roles.append(UserRoleVO(role_id=int(item["role_id"]), role_key=str(item.get("role_key") or ""), role_name=str(item.get("role_name") or ""))) + return UserVO( + id=int(Row["id"]), + username=str(Row.get("username") or ""), + nick_name=str(Row.get("nick_name") or ""), + phone_number=Row.get("phone_number"), + email=Row.get("email"), + area=Row.get("area"), + ou_name=Row.get("ou_name"), + ou_id=Row.get("ou_id"), + status=int(Row.get("status") or 0), + is_leader=bool(Row.get("is_leader", False)), + roles=roles, + tenant_name=Row.get("tenant_name"), + dep_name=Row.get("dep_name"), + ) + + def _toRouteVo(self, Row, Permissions: list[RoutePermissionVO], Enabled: bool) -> RouteVO: + """路由记录转 VO。""" + return RouteVO( + id=int(Row["id"]), + route_path=str(Row.get("route_path") or ""), + route_name=str(Row.get("route_name") or ""), + route_title=str(Row.get("route_title") or ""), + component=Row.get("component"), + parent_id=int(Row["parent_id"]) if Row.get("parent_id") is not None else None, + icon=Row.get("icon"), + sort_order=int(Row.get("sort_order") or 0), + is_hidden=bool(Row.get("is_hidden", False)), + is_cache=bool(Row.get("is_cache", True)), + status=int(Row.get("status") or 0), + enabled=Enabled, + permissions=Permissions, + children=None, + ) + + def _toRolePermissionVo(self, Row): + """角色权限记录转 VO。""" + from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import RolePermissionDetailVO + + return RolePermissionDetailVO( + id=int(Row["id"]), + permission_id=int(Row["permission_id"]), + permission_key=str(Row["permission_key"] or ""), + display_name=Row.get("display_name"), + grant_type=str(Row.get("grant_type") or "GRANT"), + data_scope=Row.get("data_scope"), + ) + + def _buildRouteTree(self, Routes: list[RouteVO]) -> list[RouteVO]: + """构建路由树。""" + routeMap = {route.id: route.model_copy(deep=True) for route in Routes} + rootRoutes: list[RouteVO] = [] + for route in routeMap.values(): + route.children = [] + for route in routeMap.values(): + if route.parent_id and route.parent_id in routeMap: + routeMap[route.parent_id].children.append(route) + else: + rootRoutes.append(route) + for route in routeMap.values(): + if route.children is not None and len(route.children) == 0: + route.children = None + elif route.children: + route.children.sort(key=lambda item: (item.sort_order, item.id)) + rootRoutes.sort(key=lambda item: (item.sort_order, item.id)) + return rootRoutes + + def _jsonDump(self, Value: Any) -> str: + """JSON 序列化。""" + import json + + return json.dumps(Value, ensure_ascii=False) + + def _toIso(self, Value) -> str | None: + """时间转 ISO 字符串。""" + if Value is None: + return None + if isinstance(Value, datetime): + return Value.isoformat() + return str(Value) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py new file mode 100644 index 0000000..e814f32 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacServiceImpl.py @@ -0,0 +1,813 @@ +"""RBAC 路由服务实现。""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from sqlalchemy import text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.domian.vo.rbacVo import RbacRouteVO, RbacUserRoutesVO +from fastapi_modules.fastapi_leaudit.services.rbacService import IRbacService + + +class RbacServiceImpl(IRbacService): + """RBAC 路由服务实现。""" + + _MINIMAL_VISIBLE_ROUTE_PREFIXES: tuple[str, ...] = ( + "/home", + "/chat-with-llm", + "/files", + "/documents", + "/settings", + "/entry-modules", + "/role-permissions", + ) + + _COMPAT_ROUTE_BLUEPRINTS: dict[str, list[dict[str, Any]]] = { + "admin": [ + { + "id": 1001, + "route_path": "/home", + "route_name": "home", + "component": "home", + "parent_id": None, + "route_title": "系统概览", + "icon": "ri-home-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "overview"}, + "children": None, + }, + { + "id": 1002, + "route_path": "/chat-with-llm", + "route_name": "chat-with-llm", + "component": "chat-with-llm", + "parent_id": None, + "route_title": "AI对话", + "icon": "ri-chat-smile-2-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "assistant"}, + "children": None, + }, + { + "id": 1003, + "route_path": "/files", + "route_name": "file-management", + "component": "files", + "parent_id": None, + "route_title": "文件管理", + "icon": "ri-folder-line", + "sort_order": 3, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + "children": [ + { + "id": 1004, + "route_path": "/files/upload", + "route_name": "file-upload", + "component": "files.upload", + "parent_id": 1003, + "route_title": "文件上传", + "icon": "ri-upload-cloud-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + "children": None, + }, + { + "id": 1005, + "route_path": "/documents", + "route_name": "documents", + "component": "documents", + "parent_id": 1003, + "route_title": "文档列表", + "icon": "ri-file-list-3-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + "children": None, + }, + ], + }, + { + "id": 1006, + "route_path": "/rules", + "route_name": "rule-management", + "component": "rules", + "parent_id": None, + "route_title": "评查规则库", + "icon": "ri-book-3-line", + "sort_order": 4, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": [ + { + "id": 1007, + "route_path": "/rule-groups", + "route_name": "rule-groups", + "component": "rule-groups", + "parent_id": 1006, + "route_title": "评查点分组", + "icon": "ri-folder-open-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": None, + }, + { + "id": 1008, + "route_path": "/rules/list", + "route_name": "rules-list", + "component": "rules.list", + "parent_id": 1006, + "route_title": "评查点列表", + "icon": "ri-list-check-3", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": None, + }, + { + "id": 1009, + "route_path": "/rules-files", + "route_name": "rules-file", + "component": "rules-files", + "parent_id": 1006, + "route_title": "评查文件列表", + "icon": "ri-list-check-2", + "sort_order": 3, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": None, + }, + ], + }, + { + "id": 1010, + "route_path": "/contract-template", + "route_name": "contract-template", + "component": "contract-template", + "parent_id": None, + "route_title": "合同模板", + "icon": "ri-file-search-line", + "sort_order": 5, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "contract"}, + "children": [ + { + "id": 1011, + "route_path": "/contract-template/search", + "route_name": "contract-search-ai", + "component": "contract-template.search", + "parent_id": 1010, + "route_title": "智能搜索", + "icon": "ri-search-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "contract"}, + "children": None, + }, + { + "id": 1012, + "route_path": "/contract-template/list", + "route_name": "contract-list", + "component": "contract-template.list", + "parent_id": 1010, + "route_title": "合同列表", + "icon": "ri-folder-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "contract"}, + "children": None, + }, + ], + }, + { + "id": 1013, + "route_path": "/settings", + "route_name": "system-settings", + "component": "settings", + "parent_id": None, + "route_title": "系统设置", + "icon": "ri-settings-4-line", + "sort_order": 6, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": [ + { + "id": 1014, + "route_path": "/entry-modules", + "route_name": "entry-modules", + "component": "entry-modules", + "parent_id": 1013, + "route_title": "入口模块管理", + "icon": "ri-apps-2-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": None, + }, + { + "id": 1015, + "route_path": "/role-permissions", + "route_name": "role-permissions", + "component": "role-permissions", + "parent_id": 1013, + "route_title": "角色权限管理", + "icon": "ri-shield-user-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": None, + }, + ], + }, + { + "id": 1018, + "route_path": "/cross-checking", + "route_name": "cross-checking", + "component": "cross-checking", + "parent_id": None, + "route_title": "交叉评查", + "icon": "ri-color-filter-line", + "sort_order": 7, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + "children": [ + { + "id": 1019, + "route_path": "/cross-checking/upload", + "route_name": "cross-checking-upload", + "component": "cross-checking.upload", + "parent_id": 1018, + "route_title": "创建任务", + "icon": "ri-upload-cloud-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + "children": None, + }, + { + "id": 1020, + "route_path": "/cross-checking/result", + "route_name": "cross-checking-result", + "component": "cross-checking.result", + "parent_id": 1018, + "route_title": "评查结果", + "icon": "ri-file-list-3-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + "children": None, + }, + ], + }, + ], + "common": [ + { + "id": 2001, + "route_path": "/home", + "route_name": "home", + "component": "home", + "parent_id": None, + "route_title": "系统概览", + "icon": "ri-home-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "overview"}, + "children": None, + }, + { + "id": 2002, + "route_path": "/files", + "route_name": "file-management", + "component": "files", + "parent_id": None, + "route_title": "文件管理", + "icon": "ri-folder-line", + "sort_order": 3, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + "children": [ + { + "id": 2003, + "route_path": "/files/upload", + "route_name": "file-upload", + "component": "files.upload", + "parent_id": 2002, + "route_title": "文件上传", + "icon": "ri-upload-cloud-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + "children": None, + }, + { + "id": 2004, + "route_path": "/documents", + "route_name": "documents", + "component": "documents", + "parent_id": 2002, + "route_title": "文档列表", + "icon": "ri-file-list-3-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "documents"}, + "children": None, + }, + ], + }, + { + "id": 2005, + "route_path": "/rules", + "route_name": "rule-management", + "component": "rules", + "parent_id": None, + "route_title": "评查规则库", + "icon": "ri-book-3-line", + "sort_order": 4, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": [ + { + "id": 2006, + "route_path": "/rule-groups", + "route_name": "rule-groups", + "component": "rule-groups", + "parent_id": 2005, + "route_title": "评查点分组", + "icon": "ri-folder-open-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": None, + }, + { + "id": 2007, + "route_path": "/rules/list", + "route_name": "rules-list", + "component": "rules.list", + "parent_id": 2005, + "route_title": "评查点列表", + "icon": "ri-list-check-3", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": None, + }, + { + "id": 2008, + "route_path": "/rules-files", + "route_name": "rules-file", + "component": "rules-files", + "parent_id": 2005, + "route_title": "评查文件列表", + "icon": "ri-list-check-2", + "sort_order": 3, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "rules"}, + "children": None, + }, + ], + }, + { + "id": 2009, + "route_path": "/contract-template", + "route_name": "contract-template", + "component": "contract-template", + "parent_id": None, + "route_title": "合同模板", + "icon": "ri-file-search-line", + "sort_order": 5, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "contract"}, + "children": [ + { + "id": 2010, + "route_path": "/contract-template/search", + "route_name": "contract-search-ai", + "component": "contract-template.search", + "parent_id": 2009, + "route_title": "智能搜索", + "icon": "ri-search-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "contract"}, + "children": None, + }, + { + "id": 2011, + "route_path": "/contract-template/list", + "route_name": "contract-list", + "component": "contract-template.list", + "parent_id": 2009, + "route_title": "合同列表", + "icon": "ri-folder-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "contract"}, + "children": None, + }, + ], + }, + { + "id": 2012, + "route_path": "/settings", + "route_name": "system-settings", + "component": "settings", + "parent_id": None, + "route_title": "系统设置", + "icon": "ri-settings-4-line", + "sort_order": 6, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": [ + { + "id": 2013, + "route_path": "/entry-modules", + "route_name": "entry-modules", + "component": "entry-modules", + "parent_id": 2012, + "route_title": "入口模块管理", + "icon": "ri-apps-2-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": None, + }, + { + "id": 2014, + "route_path": "/role-permissions", + "route_name": "role-permissions", + "component": "role-permissions", + "parent_id": 2012, + "route_title": "角色权限管理", + "icon": "ri-shield-user-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "settings"}, + "children": None, + }, + ], + }, + { + "id": 2015, + "route_path": "/cross-checking", + "route_name": "cross-checking", + "component": "cross-checking", + "parent_id": None, + "route_title": "交叉评查", + "icon": "ri-color-filter-line", + "sort_order": 7, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + "children": [ + { + "id": 2016, + "route_path": "/cross-checking/upload", + "route_name": "cross-checking-upload", + "component": "cross-checking.upload", + "parent_id": 2012, + "route_title": "创建任务", + "icon": "ri-upload-cloud-line", + "sort_order": 1, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + "children": None, + }, + { + "id": 2017, + "route_path": "/cross-checking/result", + "route_name": "cross-checking-result", + "component": "cross-checking.result", + "parent_id": 2012, + "route_title": "评查结果", + "icon": "ri-file-list-3-line", + "sort_order": 2, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + "children": None, + }, + ], + }, + ], + } + + _PERMISSION_PREFIXES_BY_PATH: dict[str, list[str]] = { + "/files": ["documents:"], + "/files/upload": ["documents:upload:"], + "/documents": ["documents:"], + "/settings": ["entry_module:", "rbac:"], + "/entry-modules": ["entry_module:"], + "/role-permissions": ["rbac:"], + "/rules": ["rules:"], + "/rule-groups": ["rules:"], + "/rules/list": ["rules:"], + "/rules-files": ["rules:"], + } + + async def GetCurrentUserRoutes(self, UserId: int) -> RbacUserRoutesVO: + """获取当前登录用户可访问的前端路由树。""" + async with GetAsyncSession() as Session: + userRow = ( + await Session.execute( + text( + """ + SELECT id, username + FROM sso_users + WHERE id = :user_id + AND deleted_at IS NULL + AND status = 0 + """ + ), + {"user_id": UserId}, + ) + ).mappings().first() + if not userRow: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在或已停用") + + roleRows = ( + await Session.execute( + text( + """ + SELECT r.id, r.role_key, COALESCE(r.priority, 0) AS priority + FROM user_role ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = :user_id + ORDER BY COALESCE(r.priority, 0) DESC, r.id ASC + """ + ), + {"user_id": UserId}, + ) + ).mappings().all() + + roleIds = [int(row["id"]) for row in roleRows] + roleKeys = [str(row["role_key"]) for row in roleRows] or ["common"] + grantedPermissions = await self._loadGrantedPermissionKeys(Session, roleIds) + databaseRoutes = await self._loadDatabaseRoutes(Session, roleIds, grantedPermissions) + + if self._isFrontendRouteSetReady(databaseRoutes): + routes = self._filterRoutesByMinimalScope(databaseRoutes) + else: + routes = self._buildCompatibilityRoutes(roleKeys, grantedPermissions) + + return RbacUserRoutesVO( + user_id=int(userRow["id"]), + username=str(userRow["username"] or ""), + roles=roleKeys, + routes=routes, + ) + + async def _loadGrantedPermissionKeys(self, Session, RoleIds: list[int]) -> set[str]: + """加载当前角色集合最终授予的权限键。""" + if not RoleIds: + return set() + + rows = ( + await Session.execute( + text( + """ + SELECT p.permission_key, rp.grant_type + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = ANY(:role_ids) + """ + ).bindparams(role_ids=RoleIds), + ) + ).fetchall() + + grants: set[str] = set() + denies: set[str] = set() + for permissionKey, grantType in rows: + if not permissionKey: + continue + if grantType == "DENY": + denies.add(str(permissionKey)) + else: + grants.add(str(permissionKey)) + return grants - denies + + async def _loadDatabaseRoutes(self, Session, RoleIds: list[int], GrantedPermissions: set[str]) -> list[RbacRouteVO]: + """按当前 role_route + sys_routes 生成路由树。""" + if not RoleIds: + return [] + + routeRows = ( + await Session.execute( + text( + """ + SELECT + sr.id, + sr.route_path, + sr.route_name, + sr.component, + sr.parent_id, + sr.route_title, + sr.icon, + sr.sort_order, + sr.is_hidden, + sr.is_cache, + sr.meta + FROM role_route rr + JOIN sys_routes sr ON sr.id = rr.route_id + WHERE rr.role_id = ANY(:role_ids) + AND rr.status = 1 + AND sr.status = 0 + AND sr.deleted_at IS NULL + ORDER BY sr.sort_order ASC, sr.id ASC + """ + ).bindparams(role_ids=RoleIds), + ) + ).mappings().all() + + routeMap: dict[int, dict[str, Any]] = {} + for row in routeRows: + routeId = int(row["id"]) + routeMap[routeId] = { + "id": routeId, + "route_path": str(row["route_path"] or ""), + "route_name": str(row["route_name"] or f"route-{routeId}"), + "component": row["component"], + "parent_id": int(row["parent_id"]) if row["parent_id"] is not None else None, + "route_title": str(row["route_title"] or row["route_name"] or ""), + "icon": row["icon"], + "sort_order": int(row["sort_order"] or 0), + "is_hidden": bool(row["is_hidden"]), + "is_cache": bool(row["is_cache"]), + "meta": self._normalizeMeta(row["meta"]), + "permissions": self._resolvePermissionsForPath(str(row["route_path"] or ""), GrantedPermissions), + "children": [], + } + + rootRoutes: list[dict[str, Any]] = [] + for route in routeMap.values(): + parentId = route["parent_id"] + if parentId is None or parentId not in routeMap: + rootRoutes.append(route) + else: + routeMap[parentId]["children"].append(route) + + return self._dictRoutesToVo(rootRoutes) + + def _isFrontendRouteSetReady(self, Routes: list[RbacRouteVO]) -> bool: + """判断数据库路由是否已经切换到当前前端真实路径集合。""" + frontendPaths = self._collectRoutePaths(Routes) + expected = { + "/files", + "/settings", + "/chat-with-llm", + "/contract-template", + "/cross-checking", + } + return any(path in frontendPaths for path in expected) + + def _buildCompatibilityRoutes(self, RoleKeys: list[str], GrantedPermissions: set[str]) -> list[RbacRouteVO]: + """当数据库路由尚未迁移到新前端路径时,返回兼容菜单树。""" + roleBucket = "admin" if any(role in {"super_admin", "provincial_admin", "admin"} for role in RoleKeys) else "common" + blueprints = deepcopy(self._COMPAT_ROUTE_BLUEPRINTS[roleBucket]) + self._attachPermissionsRecursively(blueprints, GrantedPermissions) + return self._dictRoutesToVo(self._filterBlueprintsByMinimalScope(blueprints)) + + def _filterRoutesByMinimalScope(self, Routes: list[RbacRouteVO]) -> list[RbacRouteVO]: + """按当前最小可用范围裁剪路由树。""" + filtered: list[RbacRouteVO] = [] + for route in Routes: + if not self._isRoutePathEnabled(route.route_path): + continue + + routeCopy = route.model_copy(deep=True) + routeCopy.children = self._filterRoutesByMinimalScope(route.children or []) or None + filtered.append(routeCopy) + return filtered + + def _filterBlueprintsByMinimalScope(self, Blueprints: list[dict[str, Any]]) -> list[dict[str, Any]]: + """按当前最小可用范围裁剪兼容蓝图。""" + filtered: list[dict[str, Any]] = [] + for blueprint in Blueprints: + routePath = str(blueprint.get("route_path") or "") + if not self._isRoutePathEnabled(routePath): + continue + + copied = deepcopy(blueprint) + children = copied.get("children") + if isinstance(children, list): + copied["children"] = self._filterBlueprintsByMinimalScope(children) or None + filtered.append(copied) + return filtered + + def _isRoutePathEnabled(self, RoutePath: str | None) -> bool: + """判断当前阶段是否允许暴露该前端路径。""" + if not RoutePath: + return False + + return any( + RoutePath == prefix or RoutePath.startswith(f"{prefix}/") + for prefix in self._MINIMAL_VISIBLE_ROUTE_PREFIXES + ) + + def _attachPermissionsRecursively(self, Routes: list[dict[str, Any]], GrantedPermissions: set[str]) -> None: + """递归挂接当前用户在各前端路径上的权限键。""" + for route in Routes: + routePath = str(route.get("route_path") or "") + route["permissions"] = self._resolvePermissionsForPath(routePath, GrantedPermissions) + children = route.get("children") + if isinstance(children, list) and children: + self._attachPermissionsRecursively(children, GrantedPermissions) + + def _resolvePermissionsForPath(self, RoutePath: str, GrantedPermissions: set[str]) -> list[str]: + """按当前前端路径聚合对应权限键。""" + prefixes = self._PERMISSION_PREFIXES_BY_PATH.get(RoutePath, []) + if not prefixes: + return [] + matched = sorted( + permissionKey + for permissionKey in GrantedPermissions + if any(permissionKey.startswith(prefix) for prefix in prefixes) + ) + return matched + + def _dictRoutesToVo(self, Routes: list[dict[str, Any]]) -> list[RbacRouteVO]: + """把字典路由树转换成 VO。""" + ordered = sorted(Routes, key=lambda item: (int(item.get("sort_order") or 0), int(item.get("id") or 0))) + output: list[RbacRouteVO] = [] + for route in ordered: + children = route.get("children") + childVos = self._dictRoutesToVo(children) if isinstance(children, list) and children else None + output.append( + RbacRouteVO( + id=int(route["id"]), + route_path=str(route["route_path"]), + route_name=str(route["route_name"]), + component=route.get("component"), + parent_id=int(route["parent_id"]) if route.get("parent_id") is not None else None, + route_title=str(route["route_title"]), + icon=route.get("icon"), + sort_order=int(route.get("sort_order") or 0), + is_hidden=bool(route.get("is_hidden", False)), + is_cache=bool(route.get("is_cache", False)), + meta=self._normalizeMeta(route.get("meta")), + permissions=list(route.get("permissions") or []), + children=childVos, + ) + ) + return output + + def _collectRoutePaths(self, Routes: list[RbacRouteVO]) -> set[str]: + """递归收集路由树里的全部路径。""" + paths: set[str] = set() + for route in Routes: + paths.add(route.route_path) + if route.children: + paths.update(self._collectRoutePaths(route.children)) + return paths + + @staticmethod + def _normalizeMeta(Meta: Any) -> dict | None: + """兼容 meta 为 JSON 字符串、字典或空值的情况。""" + if Meta is None: + return None + if isinstance(Meta, dict): + return Meta + return None diff --git a/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py b/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py new file mode 100644 index 0000000..5c394aa --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/rbacAdminService.py @@ -0,0 +1,100 @@ +"""RBAC 管理服务接口。""" + +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.Dto.rbacAdminDto import ( + RoleCreateDTO, + RolePermissionsBatchDTO, + RoleRoutesUpdateDTO, + RoleUpdateDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.rbacAdminVo import ( + RoleListVO, + RolePermissionsVO, + RoleRoutesVO, + RoleRouteUpdateResultVO, + RoleVO, + RoutePermissionsVO, + RouteVO, + UserListVO, + UserRolesVO, +) + + +class IRbacAdminService(ABC): + """RBAC 管理服务接口。""" + + @abstractmethod + async def ListRoles(self, CurrentUserId: int, Page: int, PageSize: int, RoleKey: str | None, RoleName: str | None, IncludeSystem: bool) -> RoleListVO: + """查询角色列表。""" + ... + + @abstractmethod + async def CreateRole(self, CurrentUserId: int, Body: RoleCreateDTO) -> RoleVO: + """创建角色。""" + ... + + @abstractmethod + async def UpdateRole(self, CurrentUserId: int, RoleId: int, Body: RoleUpdateDTO) -> RoleVO: + """更新角色。""" + ... + + @abstractmethod + async def DeleteRole(self, CurrentUserId: int, RoleId: int, Force: bool) -> None: + """删除角色。""" + ... + + @abstractmethod + async def ListUsers(self, CurrentUserId: int, Page: int, PageSize: int, Area: str | None, NickName: str | None) -> UserListVO: + """查询用户列表。""" + ... + + @abstractmethod + async def ListRoleUsers(self, CurrentUserId: int, RoleId: int, Page: int, PageSize: int, Area: str | None, UserName: str | None) -> UserListVO: + """查询指定角色下的用户列表。""" + ... + + @abstractmethod + async def AssignUserRoles(self, CurrentUserId: int, UserId: int, RoleIds: list[int]) -> UserRolesVO: + """为用户分配角色。""" + ... + + @abstractmethod + async def RevokeUserRole(self, CurrentUserId: int, UserId: int, RoleId: int) -> None: + """移除用户角色。""" + ... + + @abstractmethod + async def GetUserRoles(self, CurrentUserId: int, UserId: int) -> UserRolesVO: + """查询用户角色。""" + ... + + @abstractmethod + async def ListAllRoutes(self, CurrentUserId: int, Format: str, IncludeHidden: bool) -> list[RouteVO]: + """查询全部可管理路由。""" + ... + + @abstractmethod + async def GetRoleRoutes(self, CurrentUserId: int, RoleId: int) -> RoleRoutesVO: + """查询角色路由授权。""" + ... + + @abstractmethod + async def UpdateRoleRoutes(self, CurrentUserId: int, RoleId: int, Body: RoleRoutesUpdateDTO) -> RoleRouteUpdateResultVO: + """更新角色路由授权。""" + ... + + @abstractmethod + async def GetRolePermissions(self, CurrentUserId: int, RoleId: int) -> RolePermissionsVO: + """查询角色权限授权。""" + ... + + @abstractmethod + async def SaveRolePermissions(self, CurrentUserId: int, Body: RolePermissionsBatchDTO) -> RolePermissionsVO: + """保存角色权限授权。""" + ... + + @abstractmethod + async def GetRoutePermissions(self, CurrentUserId: int, RouteId: int) -> RoutePermissionsVO: + """查询路由关联权限定义。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/rbacService.py b/fastapi_modules/fastapi_leaudit/services/rbacService.py new file mode 100644 index 0000000..7f059e3 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/rbacService.py @@ -0,0 +1,14 @@ +"""RBAC 路由服务接口。""" + +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.vo.rbacVo import RbacUserRoutesVO + + +class IRbacService(ABC): + """RBAC 路由服务接口。""" + + @abstractmethod + async def GetCurrentUserRoutes(self, UserId: int) -> RbacUserRoutesVO: + """获取当前登录用户可访问的前端路由树。""" + ...