Files
fquiz/api/app/services/legacy_admin_rbac_service.py
T
chengkai3 fac00c0536 fix:[FL-218][角色管理列表接口返回 500]
Co-authored-by: multica-agent <github@multica.ai>
2026-06-20 16:13:47 +08:00

1441 lines
50 KiB
Python

from __future__ import annotations
import asyncio
from datetime import datetime
from uuid import uuid4
from sqlalchemy import bindparam, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from ..exceptions.menu_exceptions import (
DuplicateMenuCodeError,
EmptyMenuCodeError,
EmptyMenuNameError,
MenuValidationError,
ParentNotFoundError,
RemovedMenuCodeError,
SelfParentError,
)
from ..models.menu import Menu
from ..schemas.admin import (
MenuCreateRequest,
MenuListResponse,
MenuPublic,
MenuUpdateRequest,
RoleCreateRequest,
RoleListResponse,
RolePublic,
RolesWithMenusResponse,
RoleUpdateRequest,
)
from .audit_service import compose_audit_detail, describe_changed_fields, summarize_values, write_audit_log
from .legacy_authz_service import (
DEFAULT_ADMIN_PERMISSION_CODES,
LEGACY_URL_PATH_MAP,
MENU_CODE_PERMISSION_MAP,
)
from .push_service import publish_topic
from .user_service import queue_users_auth_refresh
PROTECTED_ROLE_IDS = {"admin", "user", "sys_mgr"}
REMOVED_MENU_CODES = {
"dashboard",
"admin.wxapp",
"admin.inbox",
"admin.code_review",
"admin.git_desktop",
"admin.agent",
"admin.mcp_server",
"admin.requirements",
"admin.schedule",
"admin.mindmap",
"admin.mermaid_mgr",
"admin.api_tester",
"admin.orchestration",
"admin.mdresolve",
"admin.data_query",
"admin.hot_search",
"admin.filedetector",
"admin.baidu_pan",
"admin.tag",
"admin.knowledge_point_mgr",
"admin.question_bank",
"admin.cron_task_mgr",
"admin.todos",
"admin.job_mgr",
"admin.jwt_generator",
"admin.queue_mgr",
"admin.knowledge_mastery",
}
PROTECTED_MENU_CODES = {
"admin.users",
"admin.roles",
"admin.menus",
"admin.system_params",
"admin.system_message",
"admin.system",
"admin.system_monitor",
"admin.wxapp",
"admin.basic_data",
"admin.files",
"admin.elevation",
"admin.tower_models",
"admin.filedetector",
"admin.baidu_pan",
"admin.power_lines",
"admin.fl_analysis",
"admin.fault_recurrence",
"admin.lightning_currents",
"admin.lightning_distribution",
"admin.workers",
"admin.task_monitor",
"admin.scheduled_tasks",
"admin.atp_models",
"admin.data_query",
"admin.hot_search",
"admin.cron_task_mgr",
"admin.mdresolve",
"admin.tag",
"admin.knowledge_point_mgr",
"admin.job_mgr",
"admin.syslog",
"admin.wine_runner",
# quiz legacy defaults
"sys_mgr",
"menu_mgr",
"role_mgr",
"user_mgr",
}
def list_roles(
db: Session,
keyword: str | None = None,
*,
limit: int | None = None,
offset: int = 0,
) -> RoleListResponse:
role_source = "legacy" if _legacy_role_table_exists(db) else "modern"
rows, total = _load_role_page(
db,
role_source=role_source,
keyword=keyword,
limit=limit,
offset=offset,
)
role_ids = [str(row["id"]) for row in rows]
role_menu_ids = _load_role_menu_ids_map(db, role_ids, role_source=role_source)
menu_rows = _load_menus_map(
db,
sorted({menu_id for ids in role_menu_ids.values() for menu_id in ids}),
role_source=role_source,
)
role_permission_codes = _load_role_permission_codes_map(db, role_ids, role_source=role_source)
items: list[RolePublic] = []
for row in rows:
role_id = str(row["id"])
role_code = role_id if role_source == "legacy" else str(row.get("code") or role_id).strip() or role_id
menu_ids = sorted(menu_id for menu_id in role_menu_ids.get(role_id, []) if menu_id in menu_rows)
permission_codes = set(role_permission_codes.get(role_id, []))
permission_codes.update(_permission_codes_from_menu_rows(menu_rows, menu_ids))
items.append(
RolePublic(
id=role_id,
code=role_code,
name=(row.get("name") or role_code).strip(),
permission_codes=sorted(permission_codes),
menu_ids=menu_ids,
)
)
return RoleListResponse(items=items, total=total)
def get_role_by_id(db: Session, role_id: str) -> RolePublic | None:
role_id = role_id.strip()
if not role_id:
return None
role_source = "legacy" if _legacy_role_table_exists(db) else "modern"
if role_source == "legacy":
rows = db.execute(
text("SELECT id, name FROM user_role WHERE id = :id"),
{"id": role_id},
).mappings().all()
role_code = role_id
resolved_role_id = role_id
else:
rows = db.execute(
text(
"""
SELECT id::text AS id, code, name
FROM roles
WHERE id::text = :id OR code = :id
LIMIT 1
"""
),
{"id": role_id},
).mappings().all()
resolved_role_id = str(rows[0]["id"]) if rows else ""
role_code = str(rows[0].get("code") or resolved_role_id).strip() if rows else ""
if not rows:
return None
role_menu_ids = _load_role_menu_ids_map(db, [resolved_role_id], role_source=role_source).get(resolved_role_id, [])
menu_rows = _load_menus_map(db, role_menu_ids, role_source=role_source)
role_permission_codes = _load_role_permission_codes_map(db, [resolved_role_id], role_source=role_source)
filtered_menu_ids = sorted(menu_id for menu_id in role_menu_ids if menu_id in menu_rows)
permission_codes = set(role_permission_codes.get(resolved_role_id, []))
permission_codes.update(_permission_codes_from_menu_rows(menu_rows, filtered_menu_ids))
return RolePublic(
id=resolved_role_id,
code=role_code or resolved_role_id,
name=(rows[0].get("name") or role_code or resolved_role_id).strip(),
permission_codes=sorted(permission_codes),
menu_ids=filtered_menu_ids,
)
def create_role(db: Session, payload: RoleCreateRequest, *, actor_user_id: str | None) -> RolePublic | None:
role_id = payload.code.strip()
if not role_id:
return None
role_name = payload.name.strip()
if not role_name:
return None
role_source = "legacy" if _legacy_role_table_exists(db) else "modern"
# Check if role code already exists
if role_source == "legacy":
existing = db.scalar(text("SELECT id FROM user_role WHERE id = :id"), {"id": role_id})
else:
existing = db.scalar(text("SELECT id FROM roles WHERE code = :code"), {"code": role_id})
if existing:
return None
menu_ids = sorted(set(menu_id.strip() for menu_id in payload.menu_ids if menu_id.strip()))
if not _menu_ids_exist(db, menu_ids, role_source=role_source):
return None
now = datetime.now()
try:
if role_source == "legacy":
db.execute(
text(
"""
INSERT INTO user_role (id, name, descr, state, create_date, update_date)
VALUES (:id, :name, :descr, 'ENABLED', :create_date, :update_date)
"""
),
{
"id": role_id,
"name": role_name,
"descr": role_name,
"create_date": now,
"update_date": now,
},
)
else:
db.execute(
text(
"""
INSERT INTO roles (code, name)
VALUES (:code, :name)
"""
),
{
"code": role_id,
"name": role_name,
},
)
_replace_role_menus_internal(db, role_id, menu_ids, role_source=role_source)
write_audit_log(
db,
action="role.create",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"role_id={role_id}",
f"role_code={role_id}",
f"role_name={role_name}",
f"menu_ids={summarize_values(menu_ids)}" if menu_ids else None,
),
)
db.commit()
except SQLAlchemyError:
db.rollback()
return None
_fire_and_forget(
publish_topic(
"admin.roles",
name="roles.changed",
payload={"action": "created", "role_id": role_id, "role_code": role_id},
requires_refetch=["/api/v1/admin/roles"],
dedupe_key=f"roles:created:{role_id}",
)
)
return get_role_by_id(db, role_id)
def update_role(db: Session, role_id: str, payload: RoleUpdateRequest, *, actor_user_id: str | None) -> RolePublic | None:
role_id = role_id.strip()
if not role_id:
return None
current_role = get_role_by_id(db, role_id)
if not current_role:
return None
role_source = "legacy" if _legacy_role_table_exists(db) else "modern"
resolved_role_id = role_id
resolved_role_code = role_id
if role_source == "legacy":
role_exists = db.scalar(text("SELECT id FROM user_role WHERE id = :id"), {"id": role_id})
if not role_exists:
return None
else:
role_row = db.execute(
text(
"""
SELECT id::text AS id, code
FROM roles
WHERE id::text = :id OR code = :id
LIMIT 1
"""
),
{"id": role_id},
).mappings().first()
if not role_row:
return None
resolved_role_id = str(role_row["id"])
resolved_role_code = str(role_row.get("code") or resolved_role_id).strip() or resolved_role_id
impacted_user_ids = _get_role_user_ids(db, resolved_role_id)
changed_fields: list[str] = []
menus_changed = False
try:
if payload.name is not None:
role_name = payload.name.strip()
if not role_name:
db.rollback()
return None
if role_name != current_role.name:
changed_fields.append("name")
if role_source == "legacy":
db.execute(
text("UPDATE user_role SET name = :name, descr = :descr, update_date = :update_date WHERE id = :id"),
{
"id": resolved_role_id,
"name": role_name,
"descr": role_name,
"update_date": datetime.now(),
},
)
else:
db.execute(
text("UPDATE roles SET name = :name WHERE id::text = :id"),
{
"id": resolved_role_id,
"name": role_name,
},
)
if payload.menu_ids is not None:
menu_ids = sorted(set(menu_id.strip() for menu_id in payload.menu_ids if menu_id.strip()))
if not _menu_ids_exist(db, menu_ids, role_source=role_source):
db.rollback()
return None
if menu_ids != sorted(current_role.menu_ids):
changed_fields.append("menu_ids")
_replace_role_menus_internal(db, resolved_role_id, menu_ids, role_source=role_source)
menus_changed = True
if changed_fields:
write_audit_log(
db,
action="role.update",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"role_id={resolved_role_id}",
f"role_code={resolved_role_code}",
f"role_name={payload.name.strip() if payload.name is not None else current_role.name}",
describe_changed_fields(changed_fields),
(
f"menu_ids={summarize_values(sorted(set(menu_id.strip() for menu_id in payload.menu_ids if menu_id.strip())))}"
if payload.menu_ids is not None and "menu_ids" in changed_fields
else None
),
),
)
db.commit()
except SQLAlchemyError:
db.rollback()
return None
if menus_changed:
queue_users_auth_refresh(db, impacted_user_ids)
_fire_and_forget(
publish_topic(
"admin.roles",
name="roles.changed",
payload={"action": "updated", "role_id": resolved_role_id, "role_code": resolved_role_code},
requires_refetch=["/api/v1/admin/roles"],
dedupe_key=f"roles:updated:{resolved_role_id}",
)
)
if menus_changed:
_fire_and_forget(
publish_topic(
"admin.menus",
name="menus.changed",
payload={"action": "role_menus_updated", "role_id": resolved_role_id, "role_code": resolved_role_code},
requires_refetch=["/api/v1/admin/menus", "/api/v1/admin/me/menus"],
dedupe_key=f"menus:role_updated:{resolved_role_id}",
)
)
return get_role_by_id(db, resolved_role_id)
def delete_role(db: Session, role_id: str, *, actor_user_id: str | None) -> bool:
role_id = role_id.strip()
if not role_id:
return False
current_role = get_role_by_id(db, role_id)
role_source = "legacy" if _legacy_role_table_exists(db) else "modern"
resolved_role_id = role_id
resolved_role_code = role_id
if role_source == "legacy":
if role_id in PROTECTED_ROLE_IDS:
return False
exists = db.scalar(text("SELECT id FROM user_role WHERE id = :id"), {"id": role_id})
if not exists:
return False
else:
role_row = db.execute(
text(
"""
SELECT id::text AS id, code
FROM roles
WHERE id::text = :id OR code = :id
LIMIT 1
"""
),
{"id": role_id},
).mappings().first()
if not role_row:
return False
resolved_role_id = str(role_row["id"])
resolved_role_code = str(role_row.get("code") or resolved_role_id).strip() or resolved_role_id
if resolved_role_code in PROTECTED_ROLE_IDS:
return False
impacted_user_ids = _get_role_user_ids(db, resolved_role_id)
has_user_role_relation = _legacy_user_role_relation_exists(db)
try:
write_audit_log(
db,
action="role.delete",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"role_id={resolved_role_id}",
f"role_code={resolved_role_code}",
f"role_name={current_role.name}" if current_role else None,
),
)
if role_source == "legacy":
db.execute(text("DELETE FROM role_menu_rela WHERE role_id = :role_id"), {"role_id": resolved_role_id})
if has_user_role_relation:
db.execute(text("DELETE FROM user_role_rela WHERE role_id = :role_id"), {"role_id": resolved_role_id})
db.execute(text("DELETE FROM user_role WHERE id = :id"), {"id": resolved_role_id})
else:
db.execute(text("DELETE FROM role_menus WHERE role_id::text = :role_id"), {"role_id": resolved_role_id})
db.execute(text("DELETE FROM role_permissions WHERE role_id::text = :role_id"), {"role_id": resolved_role_id})
db.execute(text("DELETE FROM user_roles WHERE role_id::text = :role_id"), {"role_id": resolved_role_id})
db.execute(text("DELETE FROM roles WHERE id::text = :id"), {"id": resolved_role_id})
db.commit()
except SQLAlchemyError:
db.rollback()
return False
queue_users_auth_refresh(db, impacted_user_ids)
_fire_and_forget(
publish_topic(
"admin.roles",
name="roles.changed",
payload={"action": "deleted", "role_id": resolved_role_id, "role_code": resolved_role_code},
requires_refetch=["/api/v1/admin/roles"],
dedupe_key=f"roles:deleted:{resolved_role_id}",
)
)
_fire_and_forget(
publish_topic(
"admin.menus",
name="menus.changed",
payload={"action": "role_deleted", "role_id": resolved_role_id, "role_code": resolved_role_code},
requires_refetch=["/api/v1/admin/me/menus"],
dedupe_key=f"menus:role_deleted:{resolved_role_id}",
)
)
return True
def list_permissions(_: Session) -> list[dict[str, str | int]]:
codes = sorted(set(DEFAULT_ADMIN_PERMISSION_CODES))
return [{"id": idx + 1, "code": code, "name": code} for idx, code in enumerate(codes)]
def list_menus(db: Session, keyword: str | None = None, status: str | None = None) -> MenuListResponse:
rows = _load_menus_rows(db, keyword=keyword, status=status)
items = [serialize_menu_row(row) for row in rows]
return MenuListResponse(items=items, total=len(items))
def list_roles_with_menus(
db: Session,
keyword: str | None = None,
*,
limit: int | None = None,
offset: int = 0,
) -> RolesWithMenusResponse:
"""Get roles and menus in a single request to reduce network calls."""
roles_response = list_roles(db, keyword=keyword, limit=limit, offset=offset)
menus_response = list_menus(db)
return RolesWithMenusResponse(
roles=roles_response.items,
roles_total=roles_response.total,
menus=menus_response.items,
menus_total=menus_response.total,
)
def get_menu_by_id(db: Session, menu_id: str) -> MenuPublic | None:
normalized_menu_id = menu_id.strip()
if not normalized_menu_id:
return None
row = db.execute(
text(
"""
SELECT id::text AS menu_id, code AS menu_name, name AS menu_label, type AS menu_type,
parent_id, path AS url, icon AS menu_icon, sort_order AS seq, status AS state,
permission_code AS menu_descr
FROM menus
WHERE id::text = :menu_id OR code = :menu_id
"""
),
{"menu_id": normalized_menu_id},
).mappings().first()
if not row:
return None
if str(row.get("menu_name") or "").strip() in REMOVED_MENU_CODES:
return None
return serialize_menu_row(row)
def create_menu(db: Session, payload: MenuCreateRequest, *, actor_user_id: str | None) -> MenuPublic:
menu_code = payload.code.strip()
if not menu_code:
raise EmptyMenuCodeError()
if menu_code in REMOVED_MENU_CODES:
raise RemovedMenuCodeError(menu_code)
menu_name = payload.name.strip()
if not menu_name:
raise EmptyMenuNameError()
exists = db.scalar(text("SELECT id FROM menus WHERE code = :menu_name"), {"menu_name": menu_code})
if exists:
raise DuplicateMenuCodeError(menu_code)
parent_id = payload.parent_id.strip() if payload.parent_id else None
if parent_id and parent_id == menu_code:
raise SelfParentError()
if parent_id and not db.scalar(text("SELECT id FROM menus WHERE id::text = :menu_id OR code = :menu_id"), {"menu_id": parent_id}):
raise ParentNotFoundError(parent_id)
menu_id = menu_code if len(menu_code) <= 32 else uuid4().hex
try:
result = db.execute(
text(
"""
INSERT INTO menus (
code, name, type, parent_id, path, icon, sort_order, status, permission_code,
visible, cacheable, component
)
VALUES (
:code, :name, :type, :parent_id, :path, :icon, :sort_order, :status, :permission_code,
:visible, :cacheable, :component
)
RETURNING id::text AS menu_id
"""
),
{
"code": menu_code,
"name": menu_name,
"type": payload.type,
"parent_id": int(parent_id) if parent_id and parent_id.isdigit() else None,
"path": payload.path,
"icon": payload.icon,
"sort_order": payload.sort_order,
"status": payload.status,
"permission_code": payload.permission_code or payload.component,
"visible": payload.visible,
"cacheable": payload.cacheable,
"component": payload.component,
},
)
row = result.mappings().first()
menu_id = str(row["menu_id"]) if row else menu_code
write_audit_log(
db,
action="menu.create",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"menu_id={menu_id}",
f"menu_code={menu_code}",
f"menu_name={menu_name}",
f"path={_to_legacy_url(payload.path) or ''}",
f"parent_id={parent_id}" if parent_id else None,
),
)
db.commit()
except SQLAlchemyError as e:
db.rollback()
# Re-raise with more context for database errors
raise MenuValidationError(f"数据库错误:{str(e)}") from e
_fire_and_forget(
publish_topic(
"admin.menus",
name="menus.changed",
payload={"action": "created", "menu_id": menu_id, "menu_code": menu_code},
requires_refetch=["/api/v1/admin/menus", "/api/v1/admin/me/menus"],
dedupe_key=f"menus:created:{menu_id}",
)
)
created_menu = get_menu_by_id(db, menu_id)
if not created_menu:
raise MenuValidationError(f"创建成功但无法获取菜单 {menu_id}")
return created_menu
def update_menu(db: Session, menu_id: str, payload: MenuUpdateRequest, *, actor_user_id: str | None) -> MenuPublic | None:
menu = get_menu_by_id(db, menu_id)
if not menu:
return None
update_data = payload.model_dump(exclude_unset=True)
changed_fields: list[str] = []
next_name = menu.name
if "name" in update_data and update_data["name"] is not None:
candidate_name = str(update_data["name"]).strip()
if not candidate_name:
return None
next_name = candidate_name
if candidate_name != menu.name:
changed_fields.append("name")
next_parent_id = menu.parent_id
if "parent_id" in update_data:
parent_id = update_data["parent_id"]
if parent_id:
normalized_parent = parent_id.strip()
if normalized_parent == menu.id:
return None
parent_exists = db.scalar(text("SELECT id FROM menus WHERE id::text = :menu_id OR code = :menu_id"), {"menu_id": normalized_parent})
if not parent_exists:
return None
next_parent_id = normalized_parent
else:
next_parent_id = None
if next_parent_id != menu.parent_id:
changed_fields.append("parent_id")
if "path" in update_data and update_data["path"] != menu.path:
changed_fields.append("path")
if "icon" in update_data and update_data["icon"] != menu.icon:
changed_fields.append("icon")
if "type" in update_data and update_data["type"] != menu.type:
changed_fields.append("type")
if "sort_order" in update_data and update_data["sort_order"] != menu.sort_order:
changed_fields.append("sort_order")
if "status" in update_data and update_data["status"] != menu.status:
changed_fields.append("status")
if "permission_code" in update_data and update_data["permission_code"] != menu.permission_code:
changed_fields.append("permission_code")
if "component" in update_data and update_data["component"] != menu.component:
changed_fields.append("component")
impacted_user_ids = _get_users_with_menu_access(db, menu.id)
try:
db.execute(
text(
"""
UPDATE menus
SET
name = :name,
path = :path,
icon = :icon,
parent_id = :parent_id,
type = :type,
sort_order = :sort_order,
status = :status,
permission_code = :permission_code
WHERE id::text = :menu_id OR code = :menu_id
"""
),
{
"menu_id": menu.id,
"name": next_name,
"path": update_data.get("path", menu.path),
"icon": update_data.get("icon", menu.icon),
"parent_id": int(next_parent_id) if next_parent_id and str(next_parent_id).isdigit() else None,
"type": update_data.get("type", menu.type),
"sort_order": int(update_data.get("sort_order", menu.sort_order)),
"status": update_data.get("status", menu.status),
"permission_code": update_data.get("permission_code", menu.permission_code) or update_data.get("component"),
},
)
if changed_fields:
next_status = str(update_data.get("status", menu.status))
next_path = update_data.get("path", menu.path)
write_audit_log(
db,
action="menu.update",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"menu_id={menu.id}",
f"menu_code={menu.code}",
f"menu_name={next_name}",
describe_changed_fields(changed_fields),
f"path={next_path}" if next_path else None,
(
f"status_transition={menu.status}->{next_status}"
if "status" in changed_fields
else None
),
),
)
db.commit()
except SQLAlchemyError:
db.rollback()
return None
queue_users_auth_refresh(db, impacted_user_ids)
_fire_and_forget(
publish_topic(
"admin.menus",
name="menus.changed",
payload={"action": "updated", "menu_id": menu.id, "menu_code": menu.code},
requires_refetch=["/api/v1/admin/menus", "/api/v1/admin/me/menus"],
dedupe_key=f"menus:updated:{menu.id}",
)
)
return get_menu_by_id(db, menu.id)
def delete_menu(db: Session, menu_id: str, *, actor_user_id: str | None) -> bool:
normalized_menu_id = menu_id.strip()
if not normalized_menu_id:
return False
menu_source = "legacy" if _legacy_menu_table_exists(db) else "modern"
if menu_source == "legacy":
menu = get_menu_by_id(db, normalized_menu_id)
if not menu:
return False
if menu.code in PROTECTED_MENU_CODES:
return False
child_exists = db.scalar(text("SELECT id FROM menus WHERE parent_id::text = :parent_id LIMIT 1"), {"parent_id": menu.id})
if child_exists:
return False
impacted_user_ids = _get_users_with_menu_access(db, menu.id, role_source=menu_source)
try:
write_audit_log(
db,
action="menu.delete",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"menu_id={menu.id}",
f"menu_code={menu.code}",
f"menu_name={menu.name}",
f"path={menu.path}" if menu.path else None,
),
)
db.execute(text("DELETE FROM role_menu_rela WHERE menu_id = :menu_id"), {"menu_id": menu.id})
db.execute(text("DELETE FROM menus WHERE id::text = :menu_id OR code = :menu_id"), {"menu_id": menu.id})
db.commit()
except SQLAlchemyError:
db.rollback()
return False
deleted_menu_id = menu.id
deleted_menu_code = menu.code
else:
menu_row = db.execute(
text(
"""
SELECT id::text AS menu_id, code AS menu_code
FROM menus
WHERE id::text = :menu_id OR code = :menu_id
LIMIT 1
"""
),
{"menu_id": normalized_menu_id},
).mappings().first()
if not menu_row:
return False
resolved_menu_id = str(menu_row["menu_id"])
resolved_menu_code = str(menu_row.get("menu_code") or "").strip()
if not resolved_menu_code or resolved_menu_code in PROTECTED_MENU_CODES:
return False
child_exists = db.scalar(text("SELECT id::text FROM menus WHERE parent_id::text = :parent_id LIMIT 1"), {"parent_id": resolved_menu_id})
if child_exists:
return False
impacted_user_ids = _get_users_with_menu_access(db, resolved_menu_id, role_source=menu_source)
try:
write_audit_log(
db,
action="menu.delete",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"menu_id={resolved_menu_id}",
f"menu_code={resolved_menu_code}",
),
)
db.execute(text("DELETE FROM role_menus WHERE menu_id::text = :menu_id"), {"menu_id": resolved_menu_id})
db.execute(text("DELETE FROM menus WHERE id::text = :menu_id"), {"menu_id": resolved_menu_id})
db.commit()
except SQLAlchemyError:
db.rollback()
return False
deleted_menu_id = resolved_menu_id
deleted_menu_code = resolved_menu_code
queue_users_auth_refresh(db, impacted_user_ids)
_fire_and_forget(
publish_topic(
"admin.menus",
name="menus.changed",
payload={"action": "deleted", "menu_id": deleted_menu_id, "menu_code": deleted_menu_code},
requires_refetch=["/api/v1/admin/menus", "/api/v1/admin/me/menus"],
dedupe_key=f"menus:deleted:{deleted_menu_id}",
)
)
return True
def list_role_menu_ids(db: Session, role_id: str) -> list[str] | None:
role_source = "legacy" if _legacy_role_table_exists(db) else "modern"
resolved_role_id = role_id
if role_source == "legacy":
role_exists = db.scalar(text("SELECT id FROM user_role WHERE id = :id"), {"id": role_id})
if not role_exists:
return None
else:
role_row = db.execute(
text(
"""
SELECT id::text AS id
FROM roles
WHERE id::text = :id OR code = :id
LIMIT 1
"""
),
{"id": role_id},
).mappings().first()
if not role_row:
return None
resolved_role_id = str(role_row["id"])
rows = db.execute(
text(
"SELECT menu_id FROM role_menu_rela WHERE role_id = :role_id ORDER BY menu_id ASC"
if role_source == "legacy"
else "SELECT menu_id::text AS menu_id FROM role_menus WHERE role_id::text = :role_id ORDER BY menu_id ASC"
),
{"role_id": resolved_role_id},
).all()
menu_ids = [str(row[0]) for row in rows]
menu_rows = _load_menus_map(db, menu_ids, role_source=role_source)
return [menu_id for menu_id in menu_ids if menu_id in menu_rows]
def replace_role_menus(db: Session, role_id: str, menu_ids: list[str], *, actor_user_id: str | None) -> RolePublic | None:
role_exists = db.scalar(text("SELECT id FROM user_role WHERE id = :id"), {"id": role_id})
if not role_exists:
return None
current_role = get_role_by_id(db, role_id)
normalized_menu_ids = sorted(set(menu_id.strip() for menu_id in menu_ids if menu_id.strip()))
if not _menu_ids_exist(db, normalized_menu_ids):
return None
impacted_user_ids = _get_role_user_ids(db, role_id)
try:
_replace_role_menus_internal(db, role_id, normalized_menu_ids)
write_audit_log(
db,
action="role.menus.replace",
actor_user_id=actor_user_id,
detail=compose_audit_detail(
f"role_id={role_id}",
f"role_code={current_role.code}" if current_role else f"role_code={role_id}",
f"role_name={current_role.name}" if current_role else None,
f"menu_ids={summarize_values(normalized_menu_ids)}",
),
)
db.commit()
except SQLAlchemyError:
db.rollback()
return None
queue_users_auth_refresh(db, impacted_user_ids)
_fire_and_forget(
publish_topic(
"admin.roles",
name="roles.changed",
payload={"action": "menus_replaced", "role_id": role_id, "role_code": role_id},
requires_refetch=["/api/v1/admin/roles"],
dedupe_key=f"roles:menus_replaced:{role_id}",
)
)
_fire_and_forget(
publish_topic(
"admin.menus",
name="menus.changed",
payload={"action": "role_menus_replaced", "role_id": role_id, "role_code": role_id},
requires_refetch=["/api/v1/admin/menus", "/api/v1/admin/me/menus"],
dedupe_key=f"menus:role_menus_replaced:{role_id}",
)
)
return get_role_by_id(db, role_id)
def serialize_menu_row(row: dict[str, object]) -> MenuPublic:
menu_code = str(row.get("menu_name") or row.get("menu_id") or "")
return MenuPublic(
id=str(row["menu_id"]),
code=menu_code,
name=str(row.get("menu_label") or menu_code),
path=_to_api_path(row.get("url")),
icon=(row.get("menu_icon") or None),
parent_id=(str(row["parent_id"]) if row.get("parent_id") else None),
type=_to_api_menu_type(row.get("menu_type")),
sort_order=int(row.get("seq") or 0),
status=_to_api_state(row.get("state")),
visible=_to_api_state(row.get("state")) == "enabled",
cacheable=False,
component=None,
permission_code=_primary_permission(menu_code, row.get("menu_type")),
)
def _load_role_menu_ids_map(db: Session, role_ids: list[str], *, role_source: str = "legacy") -> dict[str, list[str]]:
mapping = {role_id: [] for role_id in role_ids}
if not role_ids:
return mapping
if role_source == "legacy":
stmt = text(
"""
SELECT role_id, menu_id
FROM role_menu_rela
WHERE role_id IN :role_ids
ORDER BY menu_id ASC
"""
)
else:
stmt = text(
"""
SELECT role_id::text AS role_id, menu_id::text AS menu_id
FROM role_menus
WHERE role_id::text IN :role_ids
ORDER BY menu_id ASC
"""
)
rows = db.execute(
stmt.bindparams(bindparam("role_ids", expanding=True)),
{"role_ids": role_ids},
).mappings().all()
for row in rows:
mapping.setdefault(str(row["role_id"]), []).append(str(row["menu_id"]))
return mapping
def _load_menus_map(db: Session, menu_ids: list[str], *, role_source: str = "legacy") -> dict[str, dict[str, object]]:
if not menu_ids:
return {}
if role_source == "legacy":
stmt = text(
"""
SELECT id::text AS menu_id, code AS menu_name, type AS menu_type, status AS state
FROM menus
WHERE id::text IN :menu_ids OR code IN :menu_ids
"""
)
else:
stmt = text(
"""
SELECT id::text AS menu_id, code AS menu_name, type AS menu_type, status AS state
FROM menus
WHERE id::text IN :menu_ids
"""
)
rows = db.execute(
stmt.bindparams(bindparam("menu_ids", expanding=True)),
{"menu_ids": menu_ids},
).mappings().all()
return {
str(row["menu_id"]): dict(row)
for row in rows
if str(row.get("menu_name") or "").strip() not in REMOVED_MENU_CODES
}
def _load_role_page(
db: Session,
*,
role_source: str,
keyword: str | None = None,
limit: int | None = None,
offset: int = 0,
) -> tuple[list[dict[str, object]], int]:
normalized_offset = max(offset, 0)
normalized_limit = max(limit, 0) if limit is not None else None
params: dict[str, object] = {"offset": normalized_offset}
limit_clause = ""
if normalized_limit is not None:
limit_clause = "LIMIT :limit"
params["limit"] = normalized_limit
trimmed_keyword = keyword.strip() if keyword else ""
keyword_clause = ""
if trimmed_keyword:
keyword_clause = """
AND (
{role_keyword_predicates}
OR EXISTS (
{menu_exists_query}
)
)
"""
params["keyword"] = f"%{trimmed_keyword.lower()}%"
if role_source == "legacy":
role_keyword_predicates = """
LOWER(r.id) LIKE :keyword
OR LOWER(r.name) LIKE :keyword
"""
menu_exists_query = """
SELECT 1
FROM role_menu_rela rmr
JOIN menus m ON m.id::text = rmr.menu_id OR m.code = rmr.menu_id
WHERE rmr.role_id = r.id
AND m.code NOT IN :removed_menu_codes
AND (
LOWER(m.code) LIKE :keyword
OR LOWER(m.name) LIKE :keyword
)
"""
from_clause = """
FROM user_role r
WHERE 1 = 1
{keyword_clause}
"""
select_clause = "SELECT r.id, r.name"
order_clause = "ORDER BY r.create_date DESC NULLS LAST, r.id ASC"
else:
role_keyword_predicates = """
LOWER(r.code) LIKE :keyword
OR LOWER(r.name) LIKE :keyword
"""
menu_exists_query = """
SELECT 1
FROM role_menus rm
JOIN menus m ON m.id = rm.menu_id
WHERE rm.role_id = r.id
AND m.code NOT IN :removed_menu_codes
AND (
LOWER(m.code) LIKE :keyword
OR LOWER(m.name) LIKE :keyword
)
"""
from_clause = """
FROM roles r
WHERE 1 = 1
{keyword_clause}
"""
select_clause = "SELECT r.id::text AS id, r.code, r.name"
order_clause = "ORDER BY r.id ASC"
if keyword_clause:
keyword_clause = keyword_clause.format(
role_keyword_predicates=role_keyword_predicates,
menu_exists_query=menu_exists_query,
)
from_clause = from_clause.format(keyword_clause=keyword_clause)
query_params = {**params, "removed_menu_codes": tuple(REMOVED_MENU_CODES)} if trimmed_keyword else params
def role_page_stmt(sql: str):
stmt = text(sql)
if trimmed_keyword:
return stmt.bindparams(bindparam("removed_menu_codes", expanding=True))
return stmt
total = db.scalar(
role_page_stmt(f"SELECT COUNT(*) {from_clause}"),
query_params,
) or 0
rows = db.execute(
role_page_stmt(
f"""
{select_clause}
{from_clause}
{order_clause}
{limit_clause}
OFFSET :offset
"""
),
query_params,
).mappings().all()
return [dict(row) for row in rows], int(total)
def _load_role_permission_codes_map(
db: Session,
role_ids: list[str],
*,
role_source: str,
) -> dict[str, list[str]]:
mapping = {role_id: [] for role_id in role_ids}
if not role_ids or role_source == "legacy":
return mapping
rows = db.execute(
text(
"""
SELECT rp.role_id::text AS role_id, p.code AS permission_code
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.role_id::text IN :role_ids
ORDER BY p.code ASC
"""
).bindparams(bindparam("role_ids", expanding=True)),
{"role_ids": role_ids},
).mappings().all()
for row in rows:
role_key = str(row["role_id"])
code = str(row.get("permission_code") or "").strip()
if not code:
continue
mapping.setdefault(role_key, []).append(code)
return mapping
def _legacy_role_table_exists(db: Session) -> bool:
try:
return bool(db.scalar(text("SELECT to_regclass('public.user_role')")))
except SQLAlchemyError:
db.rollback()
return False
def _legacy_menu_table_exists(db: Session) -> bool:
try:
return bool(db.scalar(text("SELECT to_regclass('public.menu')")))
except SQLAlchemyError:
db.rollback()
return False
def _permission_codes_from_menu_rows(
menu_rows: dict[str, dict[str, object]],
menu_ids: list[str],
) -> set[str]:
codes: set[str] = set()
for menu_id in menu_ids:
row = menu_rows.get(menu_id)
if not row:
continue
if _to_api_state(row.get("state")) != "enabled":
continue
menu_name = str(row.get("menu_name") or "")
if menu_name in REMOVED_MENU_CODES:
continue
menu_type = row.get("menu_type")
mapped = MENU_CODE_PERMISSION_MAP.get(menu_name, set())
codes.update(mapped)
if not mapped and str(menu_type or "").upper() == "BUTTON" and "." in menu_name:
codes.add(menu_name)
return codes
def _menu_ids_exist(db: Session, menu_ids: list[str], *, role_source: str = "legacy") -> bool:
if not menu_ids:
return True
if role_source == "legacy":
rows = db.execute(
text("SELECT id::text AS menu_id, code AS menu_name FROM menus WHERE id::text IN :menu_ids OR code IN :menu_ids").bindparams(bindparam("menu_ids", expanding=True)),
{"menu_ids": menu_ids},
).mappings().all()
else:
rows = db.execute(
text(
"""
SELECT id::text AS menu_id, code AS menu_name
FROM menus
WHERE id::text IN :menu_ids
"""
).bindparams(bindparam("menu_ids", expanding=True)),
{"menu_ids": menu_ids},
).mappings().all()
existing = {
str(row["menu_id"])
for row in rows
if str(row.get("menu_name") or "").strip() not in REMOVED_MENU_CODES
}
return set(menu_ids).issubset(existing)
def _replace_role_menus_internal(db: Session, role_id: str, menu_ids: list[str], *, role_source: str = "legacy") -> None:
if role_source == "legacy":
db.execute(text("DELETE FROM role_menu_rela WHERE role_id = :role_id"), {"role_id": role_id})
else:
db.execute(text("DELETE FROM role_menus WHERE role_id::text = :role_id"), {"role_id": role_id})
if not menu_ids:
return
if role_source == "legacy":
stmt = text(
"""
INSERT INTO role_menu_rela (rela_id, role_id, menu_id)
VALUES (:rela_id, :role_id, :menu_id)
"""
)
for menu_id in menu_ids:
db.execute(
stmt,
{
"rela_id": uuid4().hex,
"role_id": role_id,
"menu_id": menu_id,
},
)
else:
stmt = text(
"""
INSERT INTO role_menus (role_id, menu_id)
VALUES (CAST(:role_id AS INTEGER), CAST(:menu_id AS INTEGER))
"""
)
for menu_id in menu_ids:
db.execute(
stmt,
{
"role_id": role_id,
"menu_id": menu_id,
},
)
def _legacy_user_role_relation_exists(db: Session) -> bool:
try:
return bool(db.scalar(text("SELECT to_regclass('public.user_role_rela')")))
except SQLAlchemyError:
db.rollback()
return False
def _get_role_user_ids(db: Session, role_id: str) -> list[str]:
try:
if _legacy_user_role_relation_exists(db):
rows = db.execute(
text("SELECT user_id FROM user_role_rela WHERE role_id = :role_id"),
{"role_id": role_id},
).all()
else:
rows = db.execute(
text("SELECT user_id FROM user_roles WHERE role_id::text = :role_id"),
{"role_id": role_id},
).all()
except SQLAlchemyError:
db.rollback()
return []
return sorted({str(row[0]) for row in rows})
def _get_users_with_menu_access(db: Session, menu_id: str, *, role_source: str = "legacy") -> list[str]:
try:
if role_source == "legacy":
rows = db.execute(
text(
"""
SELECT DISTINCT urr.user_id
FROM role_menu_rela rmr
JOIN user_role_rela urr ON urr.role_id = rmr.role_id
WHERE rmr.menu_id = :menu_id
"""
),
{"menu_id": menu_id},
).all()
else:
rows = db.execute(
text(
"""
SELECT DISTINCT ur.user_id
FROM role_menus rm
JOIN user_roles ur ON ur.role_id = rm.role_id
WHERE rm.menu_id::text = :menu_id
"""
),
{"menu_id": menu_id},
).all()
except SQLAlchemyError:
db.rollback()
return []
return sorted({str(row[0]) for row in rows})
def _load_menus_rows(db: Session, keyword: str | None = None, status: str | None = None) -> list[dict[str, object]]:
# Build WHERE conditions
where_clauses = []
params = {}
if keyword:
normalized_keyword = f"%{keyword.strip().lower()}%"
where_clauses.append("(LOWER(code) LIKE :keyword OR LOWER(name) LIKE :keyword OR LOWER(COALESCE(path, '')) LIKE :keyword)")
params["keyword"] = normalized_keyword
if status and status in ("enabled", "disabled"):
where_clauses.append("status = :status")
params["status"] = status
where_clause = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
try:
rows = db.execute(
text(
f"""
SELECT id::text AS menu_id, code AS menu_name, name AS menu_label, type AS menu_type,
parent_id, path AS url, icon AS menu_icon, sort_order AS seq, status AS state,
permission_code AS menu_descr
FROM menus
{where_clause}
ORDER BY sort_order ASC NULLS LAST, id ASC
"""
),
params
).mappings().all()
except SQLAlchemyError:
db.rollback()
query = db.query(Menu)
if keyword:
normalized_keyword = f"%{keyword.strip().lower()}%"
from sqlalchemy import func, or_
query = query.filter(
or_(
func.lower(Menu.code).like(normalized_keyword),
func.lower(Menu.name).like(normalized_keyword),
func.lower(func.coalesce(Menu.path, '')).like(normalized_keyword)
)
)
if status and status in ("enabled", "disabled"):
query = query.filter(Menu.status == status)
menus = query.order_by(Menu.sort_order.asc(), Menu.id.asc()).all()
rows = [
{
"menu_id": str(menu.id),
"menu_name": menu.code,
"menu_label": menu.name,
"menu_type": _to_legacy_menu_type(menu.type),
"parent_id": (str(menu.parent_id) if menu.parent_id is not None else None),
"url": _to_legacy_url(menu.path),
"menu_icon": menu.icon,
"seq": menu.sort_order,
"state": _to_legacy_state(menu.status),
"menu_descr": menu.permission_code or menu.component,
}
for menu in menus
]
return [
dict(row)
for row in rows
if str(row.get("menu_name") or "").strip() not in REMOVED_MENU_CODES
]
def _primary_permission(menu_name: str, menu_type: object) -> str | None:
mapped = MENU_CODE_PERMISSION_MAP.get(menu_name)
if mapped:
return sorted(mapped)[0]
if str(menu_type or "").upper() == "BUTTON" and "." in menu_name:
return menu_name
return None
def _to_api_state(raw_state: object) -> str:
return "enabled" if str(raw_state or "").upper() in {"ENABLED", "ACTIVE", "1", "TRUE", ""} else "disabled"
def _to_legacy_state(status: str | None) -> str:
return "DISABLED" if (status or "").strip().lower() == "disabled" else "ENABLED"
def _to_api_menu_type(raw_type: object) -> str:
value = str(raw_type or "").strip().upper()
if value == "DIRECTORY":
return "directory"
if value == "BUTTON":
return "button"
return "menu"
def _to_legacy_menu_type(raw_type: str | None) -> str:
value = (raw_type or "").strip().lower()
if value == "directory":
return "DIRECTORY"
if value == "button":
return "BUTTON"
return "MENU"
def _to_api_path(raw_url: object) -> str | None:
url = str(raw_url or "").strip()
if not url:
return None
if url in LEGACY_URL_PATH_MAP:
return LEGACY_URL_PATH_MAP[url]
if url.startswith(("http://", "https://", "/")):
return url
return f"/admin/{url}"
def _to_legacy_url(path: str | None) -> str:
normalized = (path or "").strip()
if not normalized:
return ""
reverse = {value: key for key, value in LEGACY_URL_PATH_MAP.items()}
if normalized in reverse:
return reverse[normalized]
if normalized.startswith("/admin/"):
tail = normalized.removeprefix("/admin/")
if tail:
return tail
return normalized
def _fire_and_forget(coro: object) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
close = getattr(coro, "close", None)
if callable(close):
close()
return
loop.create_task(coro)