feat: add rbac-backed settings modules

This commit is contained in:
wren
2026-04-29 22:25:06 +08:00
parent b3ad4a6f33
commit 3a58f19d6c
23 changed files with 2979 additions and 7 deletions
+20 -1
View File
@@ -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
+11 -1
View File
@@ -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:
@@ -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:
@@ -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()})
@@ -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)
@@ -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()})
@@ -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)
@@ -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="地区配置")
@@ -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列表")
@@ -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="结果消息")
@@ -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="关联文档类型列表")
@@ -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()
@@ -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()
@@ -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",
]
@@ -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:
"""上传入口模块图标。"""
...
@@ -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]:
"""获取当前用户可见的首页入口模块。"""
...
@@ -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()
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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:
"""查询路由关联权限定义。"""
...
@@ -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:
"""获取当前登录用户可访问的前端路由树。"""
...