Optimize RBAC org tree loading

This commit is contained in:
wren
2026-05-12 16:53:22 +08:00
parent cd21a82168
commit d47f499e57
6 changed files with 311 additions and 145 deletions
+1
View File
@@ -58,6 +58,7 @@ coverage.xml
# Playwright MCP cache # Playwright MCP cache
.playwright-mcp/ .playwright-mcp/
.codex-run/ .codex-run/
.chromadb_rag/
# Rules cache # Rules cache
rules/**/__pycache__/ rules/**/__pycache__/
+4
View File
@@ -33,6 +33,7 @@ celery_app = Celery(
celery_app.conf.update( celery_app.conf.update(
task_default_queue=LEAUDIT_WORKER_QUEUE_NORMAL, task_default_queue=LEAUDIT_WORKER_QUEUE_NORMAL,
imports=("fastapi_modules.fastapi_leaudit.leaudit_bridge.tasks",),
task_queues=( task_queues=(
Queue(LEAUDIT_WORKER_QUEUE_URGENT), Queue(LEAUDIT_WORKER_QUEUE_URGENT),
Queue(LEAUDIT_WORKER_QUEUE_NORMAL), Queue(LEAUDIT_WORKER_QUEUE_NORMAL),
@@ -58,3 +59,6 @@ celery_app.autodiscover_tasks(
], ],
force=True, force=True,
) )
# 显式导入任务模块,避免 worker 在某些启动方式下漏注册 bridge tasks。
from fastapi_modules.fastapi_leaudit.leaudit_bridge import tasks as _leaudit_bridge_tasks # noqa: F401,E402
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import logging
import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -43,6 +45,11 @@ from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdmin
class RbacAdminServiceImpl(IRbacAdminService): class RbacAdminServiceImpl(IRbacAdminService):
"""RBAC 管理服务实现。""" """RBAC 管理服务实现。"""
_logger = logging.getLogger("APP")
_UNGROUPED_TENANT_LABEL = "未分组租户"
_UNGROUPED_DEPARTMENT_LABEL = "未分组部门"
_UNGROUPED_ORGANIZATION_LABEL = "未分组组织"
_MANAGEABLE_ROUTE_BLUEPRINTS: list[dict[str, Any]] = [ _MANAGEABLE_ROUTE_BLUEPRINTS: list[dict[str, Any]] = [
{ {
"route_path": "/home", "route_path": "/home",
@@ -421,172 +428,277 @@ class RbacAdminServiceImpl(IRbacAdminService):
async def GetOrganizationTree(self, CurrentUserId: int, IncludeUsers: bool, RootUuid: str | None) -> OrganizationTreeVO: async def GetOrganizationTree(self, CurrentUserId: int, IncludeUsers: bool, RootUuid: str | None) -> OrganizationTreeVO:
"""查询组织树。""" """查询组织树。"""
started_at = time.perf_counter()
await self._assertManagePermission(CurrentUserId) await self._assertManagePermission(CurrentUserId)
await self._assertPermission(CurrentUserId, "rbac:users:read") await self._assertPermission(CurrentUserId, "rbac:users:read")
currentUser = await self._getCurrentUserContext(CurrentUserId) currentUser = await self._getCurrentUserContext(CurrentUserId)
root_uuid = str(RootUuid or "").strip() or None
def _normalize_group_label(value: str, empty_label: str) -> str:
return "" if value == empty_label else value
def _display_group_label(value: object, empty_label: str) -> str:
text_value = str(value or "").strip()
return text_value or empty_label
def _make_org_user(row: Any) -> OrganizationTreeUserVO:
tenant_name = str(row.get("tenant_name") or "").strip()
dep_name = str(row.get("dep_name") or "").strip()
dep_short_name = str(row.get("dep_short_name") or "").strip()
ou_name = str(row.get("ou_name") or "").strip()
return OrganizationTreeUserVO(
id=int(row["id"]),
username=str(row.get("username") or ""),
nick_name=str(row.get("nick_name") or ""),
area=row.get("area"),
ou_id=str(row.get("ou_id") or ""),
ou_name=ou_name,
is_leader=bool(row.get("is_leader", False)),
status=int(row.get("status") or 0),
tenant_name=row.get("tenant_name"),
dep_name=row.get("dep_name"),
dep_short_name=row.get("dep_short_name"),
email=row.get("email"),
phone_number=row.get("phone_number"),
organization_path=OrganizationPathVO(
tenant_name=tenant_name,
dep_name=dep_name,
dep_short_name=dep_short_name,
ou_name=ou_name,
),
)
user_filters = ["deleted_at IS NULL", "status = 0"] user_filters = ["deleted_at IS NULL", "status = 0"]
params: dict[str, object] = {} params: dict[str, object] = {}
if not currentUser["is_global"]: if not currentUser["is_global"]:
user_filters.append("COALESCE(area, '') = :user_area") user_filters.append("COALESCE(area, '') = :user_area")
params["user_area"] = currentUser["area"] params["user_area"] = currentUser["area"]
if root_uuid:
if root_uuid.startswith("tenant__"):
tenant_label = _normalize_group_label(root_uuid.removeprefix("tenant__"), self._UNGROUPED_TENANT_LABEL)
user_filters.append("COALESCE(tenant_name, '') = :tenant_name")
params["tenant_name"] = tenant_label
elif root_uuid.startswith("dep__"):
tenant_label, _, dep_label = root_uuid.removeprefix("dep__").partition("__")
tenant_label = _normalize_group_label(tenant_label, self._UNGROUPED_TENANT_LABEL)
dep_label = _normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL)
user_filters.append("COALESCE(tenant_name, '') = :tenant_name")
user_filters.append("COALESCE(dep_name, dep_short_name, '') = :dep_name")
params["tenant_name"] = tenant_label
params["dep_name"] = dep_label
elif root_uuid.startswith("org__"):
tenant_label, _, remainder = root_uuid.removeprefix("org__").partition("__")
dep_label, _, org_label = remainder.partition("__")
tenant_label = _normalize_group_label(tenant_label, self._UNGROUPED_TENANT_LABEL)
dep_label = _normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL)
org_label = _normalize_group_label(org_label, self._UNGROUPED_ORGANIZATION_LABEL)
user_filters.append("COALESCE(tenant_name, '') = :tenant_name")
user_filters.append("COALESCE(dep_name, dep_short_name, '') = :dep_name")
user_filters.append("COALESCE(ou_name, '') = :ou_name")
params["tenant_name"] = tenant_label
params["dep_name"] = dep_label
params["ou_name"] = org_label
else:
user_filters.append("COALESCE(ou_id, '') = :root_ou_id")
params["root_ou_id"] = root_uuid
where_clause = " AND ".join(user_filters) where_clause = " AND ".join(user_filters)
async with GetAsyncSession() as Session: async with GetAsyncSession() as Session:
user_rows = ( total_users = int(
await Session.execute( (
text( await Session.execute(
f""" text(
SELECT f"""
id, SELECT COUNT(1)
username, FROM sso_users
nick_name, WHERE {where_clause}
area, """
ou_id,
ou_name,
is_leader,
status,
tenant_name,
dep_name,
dep_short_name,
email,
phone_number
FROM sso_users
WHERE {where_clause}
ORDER BY COALESCE(tenant_name, '') ASC, COALESCE(dep_name, '') ASC, COALESCE(ou_name, '') ASC, id ASC
"""
),
params,
)
).mappings().all()
root_uuid = str(RootUuid or "").strip() or None
tenant_nodes: dict[str, OrganizationNodeVO] = {}
department_nodes: dict[str, OrganizationNodeVO] = {}
organization_nodes: dict[str, OrganizationNodeVO] = {}
department_children: dict[str, list[str]] = {}
organization_children: dict[str, list[str]] = {}
users_by_org: dict[str, list[OrganizationTreeUserVO]] = {}
total_users = len(user_rows)
for row in user_rows:
ou_id = str(row.get("ou_id") or "").strip()
ou_name = str(row.get("ou_name") or "").strip() or ou_id
tenant_name = str(row.get("tenant_name") or "").strip()
dep_name = str(row.get("dep_name") or "").strip()
dep_short_name = str(row.get("dep_short_name") or "").strip()
tenant_label = tenant_name or "未分组租户"
department_label = dep_name or dep_short_name or "未分组部门"
organization_label = ou_name or "未分组组织"
tenant_key = f"tenant__{tenant_label}"
dep_key = f"dep__{tenant_label}__{department_label}"
org_key = ou_id or f"org__{tenant_label}__{department_label}__{organization_label}"
if tenant_key not in tenant_nodes:
tenant_nodes[tenant_key] = OrganizationNodeVO(
ou_id=tenant_key,
ou_name=tenant_label,
parent_ou_id=None,
level=1,
)
if dep_key not in department_nodes:
department_nodes[dep_key] = OrganizationNodeVO(
ou_id=dep_key,
ou_name=department_label,
parent_ou_id=tenant_key,
level=2,
)
department_children.setdefault(tenant_key, []).append(dep_key)
if org_key not in organization_nodes:
organization_nodes[org_key] = OrganizationNodeVO(
ou_id=org_key,
ou_name=organization_label,
parent_ou_id=dep_key,
level=3,
)
organization_children.setdefault(dep_key, []).append(org_key)
if IncludeUsers and ou_id:
users_by_org.setdefault(org_key, []).append(
OrganizationTreeUserVO(
id=int(row["id"]),
username=str(row.get("username") or ""),
nick_name=str(row.get("nick_name") or ""),
area=row.get("area"),
ou_id=ou_id,
ou_name=ou_name,
is_leader=bool(row.get("is_leader", False)),
status=int(row.get("status") or 0),
tenant_name=row.get("tenant_name"),
dep_name=row.get("dep_name"),
dep_short_name=row.get("dep_short_name"),
email=row.get("email"),
phone_number=row.get("phone_number"),
organization_path=OrganizationPathVO(
tenant_name=tenant_name,
dep_name=dep_name,
dep_short_name=dep_short_name,
ou_name=ou_name,
), ),
params,
) )
) ).scalar()
or 0
for org_key, users in users_by_org.items():
users.sort(key=lambda item: (not item.is_leader, item.nick_name, item.username, item.id))
node = organization_nodes.get(org_key)
if node:
node.users = users
def clone_node(source: OrganizationNodeVO, *, children: list[OrganizationNodeVO] | None = None, users: list[OrganizationTreeUserVO] | None = None) -> OrganizationNodeVO:
return OrganizationNodeVO(
ou_id=source.ou_id,
ou_name=source.ou_name,
parent_ou_id=source.parent_ou_id,
level=source.level,
children=children or [],
users=users or [],
) )
if root_uuid: query_rows_count = 0
if root_uuid in tenant_nodes: organizations: list[OrganizationNodeVO]
tenant = tenant_nodes[root_uuid]
child_nodes = [
clone_node(department_nodes[dep_id])
for dep_id in sorted(set(department_children.get(root_uuid, [])), key=lambda item: (department_nodes[item].ou_name, item))
]
organizations = [clone_node(tenant, children=child_nodes)]
total_organizations = 1 + len(child_nodes)
elif root_uuid in department_nodes:
department = department_nodes[root_uuid]
child_nodes = [
clone_node(organization_nodes[org_id])
for org_id in sorted(set(organization_children.get(root_uuid, [])), key=lambda item: (organization_nodes[item].ou_name, item))
]
organizations = [clone_node(department, children=child_nodes)]
total_organizations = 1 + len(child_nodes)
elif root_uuid in organization_nodes:
organization = organization_nodes[root_uuid]
organization_users = list(users_by_org.get(root_uuid, [])) if IncludeUsers else []
organizations = [clone_node(organization, users=organization_users)]
total_organizations = 1
total_users = len(organization_users) if IncludeUsers else total_users
else:
organizations = []
total_organizations = 0
total_users = 0
else:
organizations = [
clone_node(tenant_nodes[tenant_id])
for tenant_id in sorted(tenant_nodes.keys(), key=lambda item: (tenant_nodes[item].ou_name, item))
]
total_organizations = len(organizations)
return OrganizationTreeVO( if not root_uuid:
tenant_rows = (
await Session.execute(
text(
f"""
SELECT DISTINCT COALESCE(tenant_name, '') AS tenant_name
FROM sso_users
WHERE {where_clause}
ORDER BY COALESCE(tenant_name, '') ASC
"""
),
params,
)
).mappings().all()
query_rows_count = len(tenant_rows)
organizations = [
OrganizationNodeVO(
ou_id=f"tenant__{_display_group_label(row.get('tenant_name'), self._UNGROUPED_TENANT_LABEL)}",
ou_name=_display_group_label(row.get("tenant_name"), self._UNGROUPED_TENANT_LABEL),
parent_ou_id=None,
level=1,
)
for row in tenant_rows
]
total_organizations = len(organizations)
elif root_uuid.startswith("tenant__"):
tenant_label = _display_group_label(params.get("tenant_name"), self._UNGROUPED_TENANT_LABEL)
dep_rows = (
await Session.execute(
text(
f"""
SELECT DISTINCT
COALESCE(dep_name, dep_short_name, '') AS department_name
FROM sso_users
WHERE {where_clause}
ORDER BY COALESCE(dep_name, dep_short_name, '') ASC
"""
),
params,
)
).mappings().all()
query_rows_count = len(dep_rows)
child_nodes = [
OrganizationNodeVO(
ou_id=f"dep__{tenant_label}__{_display_group_label(row.get('department_name'), self._UNGROUPED_DEPARTMENT_LABEL)}",
ou_name=_display_group_label(row.get("department_name"), self._UNGROUPED_DEPARTMENT_LABEL),
parent_ou_id=root_uuid,
level=2,
)
for row in dep_rows
]
organizations = [
OrganizationNodeVO(
ou_id=root_uuid,
ou_name=tenant_label,
parent_ou_id=None,
level=1,
children=child_nodes,
)
]
total_organizations = 1 + len(child_nodes)
elif root_uuid.startswith("dep__"):
tenant_label, _, dep_label = root_uuid.removeprefix("dep__").partition("__")
org_rows = (
await Session.execute(
text(
f"""
SELECT DISTINCT
COALESCE(ou_id, '') AS ou_id,
COALESCE(ou_name, '') AS ou_name
FROM sso_users
WHERE {where_clause}
ORDER BY COALESCE(ou_name, '') ASC, COALESCE(ou_id, '') ASC
"""
),
params,
)
).mappings().all()
query_rows_count = len(org_rows)
child_nodes = [
OrganizationNodeVO(
ou_id=str(row.get("ou_id") or "").strip()
or f"org__{tenant_label}__{dep_label}__{_display_group_label(row.get('ou_name'), self._UNGROUPED_ORGANIZATION_LABEL)}",
ou_name=_display_group_label(row.get("ou_name"), self._UNGROUPED_ORGANIZATION_LABEL),
parent_ou_id=root_uuid,
level=3,
)
for row in org_rows
]
organizations = [
OrganizationNodeVO(
ou_id=root_uuid,
ou_name=_display_group_label(_normalize_group_label(dep_label, self._UNGROUPED_DEPARTMENT_LABEL), self._UNGROUPED_DEPARTMENT_LABEL),
parent_ou_id=f"tenant__{tenant_label}",
level=2,
children=child_nodes,
)
]
total_organizations = 1 + len(child_nodes)
else:
user_rows = (
await Session.execute(
text(
f"""
SELECT
id,
username,
nick_name,
area,
ou_id,
ou_name,
is_leader,
status,
tenant_name,
dep_name,
dep_short_name,
email,
phone_number
FROM sso_users
WHERE {where_clause}
ORDER BY is_leader DESC, COALESCE(nick_name, '') ASC, COALESCE(username, '') ASC, id ASC
"""
),
params,
)
).mappings().all()
query_rows_count = len(user_rows)
users = [_make_org_user(row) for row in user_rows] if IncludeUsers else []
total_users = len(user_rows) if IncludeUsers else total_users
if root_uuid.startswith("org__"):
tenant_label, _, remainder = root_uuid.removeprefix("org__").partition("__")
dep_label, _, org_label = remainder.partition("__")
organizations = [
OrganizationNodeVO(
ou_id=root_uuid,
ou_name=_display_group_label(_normalize_group_label(org_label, self._UNGROUPED_ORGANIZATION_LABEL), self._UNGROUPED_ORGANIZATION_LABEL),
parent_ou_id=f"dep__{tenant_label}__{dep_label}",
level=3,
users=users,
)
]
else:
first_row = user_rows[0] if user_rows else {}
tenant_label = _display_group_label(first_row.get("tenant_name"), self._UNGROUPED_TENANT_LABEL)
dep_label = _display_group_label(first_row.get("dep_name") or first_row.get("dep_short_name"), self._UNGROUPED_DEPARTMENT_LABEL)
org_label = _display_group_label(first_row.get("ou_name"), self._UNGROUPED_ORGANIZATION_LABEL)
organizations = [
OrganizationNodeVO(
ou_id=root_uuid,
ou_name=org_label,
parent_ou_id=f"dep__{tenant_label}__{dep_label}",
level=3,
users=users,
)
]
total_organizations = 1 if organizations else 0
result = OrganizationTreeVO(
organizations=organizations, organizations=organizations,
total_organizations=total_organizations, total_organizations=total_organizations,
total_users=total_users, total_users=total_users,
) )
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
self._logger.info(
"rbac organization tree built: user=%s include_users=%s root=%s rows=%s orgs=%s users=%s duration_ms=%s",
CurrentUserId,
IncludeUsers,
root_uuid or "root",
query_rows_count,
result.total_organizations,
result.total_users,
duration_ms,
)
return result
async def ListRoleUsers(self, CurrentUserId: int, RoleId: int, Page: int, PageSize: int, Area: str | None, UserName: str | None) -> UserListVO: async def ListRoleUsers(self, CurrentUserId: int, RoleId: int, Page: int, PageSize: int, Area: str | None, UserName: str | None) -> UserListVO:
"""查询指定角色下的用户列表。""" """查询指定角色下的用户列表。"""
Submodule legal-platform-frontend added at 444c435fe0
@@ -0,0 +1,36 @@
-- ============================================================================
-- sso_users 组织树查询索引补丁
-- 用途:
-- 1. 优化 /api/admin/users/organizations/tree 的分层懒加载查询
-- 2. 重点覆盖 tenant / dep / org / ou_id 四类过滤路径
-- 注意:
-- 1. 该脚本适合已存在 sso_users 表的环境增量执行
-- 2. 如果正式库较大,建议低峰执行;如需 CREATE INDEX CONCURRENTLY,请拆成单条执行
-- ============================================================================
BEGIN;
-- 根节点:按地区 + 有效用户状态枚举租户
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_tenant
ON sso_users(status, deleted_at, area, tenant_name);
-- 租户节点:按地区 / 租户 枚举部门,兼容 dep_name / dep_short_name
CREATE INDEX IF NOT EXISTS idx_sso_users_active_tenant_dep_expr
ON sso_users(status, deleted_at, tenant_name, (COALESCE(dep_name, dep_short_name, '')));
-- 部门节点:按租户 + 部门 枚举组织
CREATE INDEX IF NOT EXISTS idx_sso_users_active_tenant_dep_ou_name
ON sso_users(status, deleted_at, tenant_name, (COALESCE(dep_name, dep_short_name, '')), ou_name);
-- 叶子节点:按地区快速命中组织名称 / 组织ID
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_ou_name
ON sso_users(status, deleted_at, area, ou_name);
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_ou_id
ON sso_users(status, deleted_at, area, ou_id);
-- 部门展开时若只按 area + department 过滤,也给表达式索引
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_dep_expr
ON sso_users(status, deleted_at, area, (COALESCE(dep_name, dep_short_name, '')));
COMMIT;
@@ -50,6 +50,18 @@ CREATE INDEX IF NOT EXISTS idx_sso_users_ou_id ON sso_users(ou_id);
CREATE INDEX IF NOT EXISTS idx_sso_users_is_leader ON sso_users(is_leader); CREATE INDEX IF NOT EXISTS idx_sso_users_is_leader ON sso_users(is_leader);
CREATE INDEX IF NOT EXISTS idx_sso_users_mq_person ON sso_users(mq_person_uuid); CREATE INDEX IF NOT EXISTS idx_sso_users_mq_person ON sso_users(mq_person_uuid);
CREATE INDEX IF NOT EXISTS idx_sso_users_mq_account ON sso_users(mq_account_uuid); CREATE INDEX IF NOT EXISTS idx_sso_users_mq_account ON sso_users(mq_account_uuid);
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_tenant
ON sso_users(status, deleted_at, area, tenant_name);
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_dep_expr
ON sso_users(status, deleted_at, area, (COALESCE(dep_name, dep_short_name, '')));
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_ou_name
ON sso_users(status, deleted_at, area, ou_name);
CREATE INDEX IF NOT EXISTS idx_sso_users_active_area_ou_id
ON sso_users(status, deleted_at, area, ou_id);
CREATE INDEX IF NOT EXISTS idx_sso_users_active_tenant_dep_expr
ON sso_users(status, deleted_at, tenant_name, (COALESCE(dep_name, dep_short_name, '')));
CREATE INDEX IF NOT EXISTS idx_sso_users_active_tenant_dep_ou_name
ON sso_users(status, deleted_at, tenant_name, (COALESCE(dep_name, dep_short_name, '')), ou_name);
COMMENT ON TABLE sso_users IS '用户主表:认证身份、组织信息、地区隔离基础字段统一沉淀在这里'; COMMENT ON TABLE sso_users IS '用户主表:认证身份、组织信息、地区隔离基础字段统一沉淀在这里';
COMMENT ON COLUMN sso_users.sub IS '统一身份唯一标识,OAuth / SSO 主键'; COMMENT ON COLUMN sso_users.sub IS '统一身份唯一标识,OAuth / SSO 主键';