feat: add rbac-backed settings modules
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user