diff --git a/.gitignore b/.gitignore index 2eeb553..3d80284 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ coverage.xml # Playwright MCP cache .playwright-mcp/ .codex-run/ +.chromadb_rag/ # Rules cache rules/**/__pycache__/ diff --git a/fastapi_admin/celery_app.py b/fastapi_admin/celery_app.py index 5c0fb97..636dd50 100644 --- a/fastapi_admin/celery_app.py +++ b/fastapi_admin/celery_app.py @@ -33,6 +33,7 @@ celery_app = Celery( celery_app.conf.update( task_default_queue=LEAUDIT_WORKER_QUEUE_NORMAL, + imports=("fastapi_modules.fastapi_leaudit.leaudit_bridge.tasks",), task_queues=( Queue(LEAUDIT_WORKER_QUEUE_URGENT), Queue(LEAUDIT_WORKER_QUEUE_NORMAL), @@ -58,3 +59,6 @@ celery_app.autodiscover_tasks( ], force=True, ) + +# 显式导入任务模块,避免 worker 在某些启动方式下漏注册 bridge tasks。 +from fastapi_modules.fastapi_leaudit.leaudit_bridge import tasks as _leaudit_bridge_tasks # noqa: F401,E402 diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py index 259279d..1979741 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging +import time from datetime import datetime from typing import Any @@ -43,6 +45,11 @@ from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdmin class RbacAdminServiceImpl(IRbacAdminService): """RBAC 管理服务实现。""" + _logger = logging.getLogger("APP") + _UNGROUPED_TENANT_LABEL = "未分组租户" + _UNGROUPED_DEPARTMENT_LABEL = "未分组部门" + _UNGROUPED_ORGANIZATION_LABEL = "未分组组织" + _MANAGEABLE_ROUTE_BLUEPRINTS: list[dict[str, Any]] = [ { "route_path": "/home", @@ -421,172 +428,277 @@ class RbacAdminServiceImpl(IRbacAdminService): async def GetOrganizationTree(self, CurrentUserId: int, IncludeUsers: bool, RootUuid: str | None) -> OrganizationTreeVO: """查询组织树。""" + started_at = time.perf_counter() await self._assertManagePermission(CurrentUserId) await self._assertPermission(CurrentUserId, "rbac:users:read") 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"] params: dict[str, object] = {} if not currentUser["is_global"]: user_filters.append("COALESCE(area, '') = :user_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) async with GetAsyncSession() as Session: - 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 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, + total_users = int( + ( + await Session.execute( + text( + f""" + SELECT COUNT(1) + FROM sso_users + WHERE {where_clause} + """ ), + params, ) - ) - - 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 [], + ).scalar() + or 0 ) - if root_uuid: - if root_uuid in tenant_nodes: - 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) + query_rows_count = 0 + organizations: list[OrganizationNodeVO] - 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, total_organizations=total_organizations, 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: """查询指定角色下的用户列表。""" diff --git a/legal-platform-frontend b/legal-platform-frontend new file mode 160000 index 0000000..444c435 --- /dev/null +++ b/legal-platform-frontend @@ -0,0 +1 @@ +Subproject commit 444c435fe00003c1413515262aec1b1811db1932 diff --git a/scripts/创建sql/sso_users_org_tree_indexes.sql b/scripts/创建sql/sso_users_org_tree_indexes.sql new file mode 100644 index 0000000..9a3bcb1 --- /dev/null +++ b/scripts/创建sql/sso_users_org_tree_indexes.sql @@ -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; diff --git a/scripts/创建sql/user_rbac_schema_patch.sql b/scripts/创建sql/user_rbac_schema_patch.sql index 9fe2266..bf661cc 100644 --- a/scripts/创建sql/user_rbac_schema_patch.sql +++ b/scripts/创建sql/user_rbac_schema_patch.sql @@ -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_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_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 COLUMN sso_users.sub IS '统一身份唯一标识,OAuth / SSO 主键';