dingdanquanliucheng/backend/app/services/system_service.py
taiyi 9b32afdbb4 为后端全部 86 个 Python 文件添加中文注释
覆盖所有模块:
- api 层:17 个路由文件,每个接口标注用途、参数、返回值、权限
- services 层:18 个服务文件,每个方法标注作用、参数、返回值、调用方
- repositories 层:13 个仓储文件,每个方法标注查询逻辑和被调用方
- schemas 层:11 个请求/响应体文件,每个字段标注业务含义
- core 层:config、security、exceptions、responses、error_codes
- models 层:19 个 ORM 模型类,每个表标注业务含义和关联关系
- scripts:bootstrap_data、smoke_check
- migrations:env.py 和版本迁移文件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 07:23:33 +08:00

886 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""系统管理服务模块
负责后台系统管理的核心业务逻辑,包括:
- 用户管理(增删改查、密码重置、启用/停用)
- 角色管理(增删改查、启用/停用)
- 菜单与权限管理(增删改查、角色菜单授权、菜单树构建)
所有写操作均会记录审计日志。
被调用方system 路由(用户、角色、菜单相关的全部管理接口)。
"""
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.core.security import hash_password
from backend.app.repositories.system_repository import SystemRepository
from backend.app.services.audit_service import audit_service
class SystemService:
"""系统管理业务服务。
依赖:
- SystemRepository用户、角色、菜单的数据库操作。
- AuditService操作审计日志记录。
"""
VALID_MENU_TYPES = {"catalog", "page", "button"}
def __init__(self) -> None:
self.repository = SystemRepository()
def list_users(self, session: Session | None = None, filters: dict | None = None) -> dict:
"""查询系统用户列表。
参数:
session: 数据库会话,不可为 None。
filters: 可选的筛选条件。
返回:
包含 total、page_no、page_size、list 的分页结果字典。
被调用方system 路由(用户列表接口)。
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
rows = self.repository.list_users(session, filters or {})
return {
"total": len(rows),
"page_no": 1,
"page_size": len(rows) or 20,
"list": [self._build_user_row(user, role) for user, role in rows],
}
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def create_user(self, payload: dict, session: Session | None = None) -> dict:
"""创建系统用户。
校验用户名唯一性和角色有效性,对密码做哈希处理后保存,同时记录审计日志。
参数:
payload: 用户信息字典,必含 username、password、real_name、role_id 等。
session: 数据库会话,不可为 None。
返回:
包含 user_id、username 等字段的字典。
被调用方system 路由(用户创建接口)。
"""
self._validate_create_user(payload)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
existed = self.repository.get_user_by_username(session, payload["username"])
if existed is not None:
raise AppException(code=ErrorCode.DUPLICATE, message="用户名已存在", status_code=400)
role = self.repository.get_role(session, payload["role_id"])
if role is None or role.status != 1:
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在或已停用", status_code=404)
user = self.repository.create_user(
session,
{
**payload,
"password_hash": hash_password(payload["password"]),
},
)
audit_service.write_log(
session,
{
"operate_type": "system_user_create",
"biz_type": "sys_user",
"biz_id": user.id,
"before_value": None,
"after_value": self._build_user_row(user, role),
"remark": f"新增用户 {user.username}",
},
)
session.commit()
return {
"user_id": user.id,
"username": user.username,
"real_name": user.real_name,
"mobile": user.mobile,
"role_id": user.role_id,
"status": user.status,
}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def update_user(self, user_id: int, payload: dict, session: Session | None = None) -> dict:
"""更新系统用户信息。
参数:
user_id: 用户 ID。
payload: 要更新的用户信息字典。
session: 数据库会话,不可为 None。
返回:
包含 user_id 和 updated 标志的字典。
被调用方system 路由(用户更新接口)。
"""
self._validate_update_user(payload)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
user = self.repository.get_user(session, user_id)
if user is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="用户不存在", status_code=404)
role = self.repository.get_role(session, payload["role_id"])
if role is None or role.status != 1:
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在或已停用", status_code=404)
before_snapshot = self._build_user_row(user, self.repository.get_role(session, user.role_id))
self.repository.update_user(user, payload)
audit_service.write_log(
session,
{
"operate_type": "system_user_update",
"biz_type": "sys_user",
"biz_id": user.id,
"before_value": before_snapshot,
"after_value": self._build_user_row(user, role),
"remark": f"更新用户 {user.username}",
},
)
session.commit()
return {"user_id": user_id, "updated": True}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def reset_password(self, user_id: int, new_password: str, session: Session | None = None) -> dict:
"""重置用户密码。
参数:
user_id: 用户 ID。
new_password: 新密码明文(不少于 6 位)。
session: 数据库会话,不可为 None。
返回:
包含 user_id 和 reset 标志的字典。
被调用方system 路由(密码重置接口)。
"""
self._validate_password(new_password)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
user = self.repository.get_user(session, user_id)
if user is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="用户不存在", status_code=404)
before_hash = user.password_hash
self.repository.reset_password(user, hash_password(new_password))
audit_service.write_log(
session,
{
"operate_type": "system_user_reset_password",
"biz_type": "sys_user",
"biz_id": user.id,
"before_value": {"password_hash": before_hash},
"after_value": {"password_hash": user.password_hash},
"remark": f"重置用户密码 {user.username}",
},
)
session.commit()
return {"user_id": user_id, "reset": True}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def change_user_status(self, user_id: int, status: int, session: Session | None = None, current_user: dict | None = None) -> dict:
"""启用或停用用户。
包含安全校验:不能修改自己的状态,不能停用最后一个管理员。
参数:
user_id: 用户 ID。
status: 目标状态0=停用1=启用。
session: 数据库会话,不可为 None。
current_user: 当前登录用户信息,用于防止自操作。
返回:
包含 user_id 和 status 的字典。
被调用方system 路由(用户状态变更接口)。
"""
if status not in (0, 1):
raise AppException(code=ErrorCode.PARAM_ERROR, message="状态值必须为 0 或 1", status_code=400)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
user = self.repository.get_user(session, user_id)
if user is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="用户不存在", status_code=404)
if current_user and current_user.get("user_id") == user_id:
raise AppException(code=ErrorCode.PARAM_ERROR, message="不能修改自己的状态", status_code=400)
if status == 0:
role = self.repository.get_role(session, user.role_id)
if role and role.role_code == "admin":
from sqlalchemy import func, select
from backend.app.models.system import SysUser
active_admin_count = session.execute(
select(func.count()).select_from(SysUser).where(
SysUser.role_id == user.role_id,
SysUser.status == 1,
SysUser.deleted == 0,
SysUser.id != user_id,
)
).scalar() or 0
if active_admin_count == 0:
raise AppException(code=ErrorCode.PARAM_ERROR, message="不能停用最后一个管理员", status_code=400)
before_status = user.status
user.status = status
session.add(user)
audit_service.write_log(
session,
{
"operate_type": "system_user_status_change",
"biz_type": "sys_user",
"biz_id": user.id,
"before_value": {"status": before_status},
"after_value": {"status": status},
"remark": f"{'启用' if status == 1 else '停用'}用户 {user.username}",
},
)
session.commit()
return {"user_id": user_id, "status": status}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def list_roles(self, session: Session | None = None, filters: dict | None = None) -> dict:
"""查询角色列表。
参数:
session: 数据库会话,不可为 None。
filters: 可选的筛选条件。
返回:
包含 total、page_no、page_size、list 的分页结果字典。
被调用方system 路由(角色列表接口)。
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
roles = self.repository.list_roles(session, filters or {})
return {
"total": len(roles),
"page_no": 1,
"page_size": len(roles) or 20,
"list": [
{
"role_id": role.id,
"role_name": role.role_name,
"role_code": role.role_code,
"status": role.status,
"remark": role.remark,
"created_at": role.created_at.strftime("%Y-%m-%d %H:%M:%S") if role.created_at else "",
}
for role in roles
],
}
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def create_role(self, payload: dict, session: Session | None = None) -> dict:
"""创建角色。
校验角色编码唯一性后保存,同时记录审计日志。
参数:
payload: 角色信息字典,必含 role_name、role_code。
session: 数据库会话,不可为 None。
返回:
包含 role_id、role_name、role_code、status 的字典。
被调用方system 路由(角色创建接口)。
"""
self._validate_create_role(payload)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
existed = self.repository.get_role_by_code(session, payload["role_code"])
if existed is not None:
raise AppException(code=ErrorCode.DUPLICATE, message="角色编码已存在", status_code=400)
role = self.repository.create_role(session, payload)
audit_service.write_log(
session,
{
"operate_type": "system_role_create",
"biz_type": "sys_role",
"biz_id": role.id,
"before_value": None,
"after_value": self._build_role_row(role),
"remark": f"新增角色 {role.role_code}",
},
)
session.commit()
return {
"role_id": role.id,
"role_name": role.role_name,
"role_code": role.role_code,
"status": role.status,
}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def update_role(self, role_id: int, payload: dict, session: Session | None = None) -> dict:
"""更新角色信息。
包含业务校验:管理员角色不能停用,停用时不能有启用的用户。
参数:
role_id: 角色 ID。
payload: 要更新的角色信息字典。
session: 数据库会话,不可为 None。
返回:
包含 role_id 和 updated 标志的字典。
被调用方system 路由(角色更新接口)。
"""
self._validate_update_role(payload)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
role = self.repository.get_role(session, role_id)
if role is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在", status_code=404)
if role.role_code == "admin" and payload.get("status", 1) != 1:
raise AppException(code=ErrorCode.BUSINESS_RULE_FAILED, message="管理员角色不能停用", status_code=400)
if payload.get("status", 1) != 1 and self.repository.count_active_users_by_role(session, role_id) > 0:
raise AppException(code=ErrorCode.BUSINESS_RULE_FAILED, message="当前角色下仍有启用用户", status_code=400)
before_snapshot = self._build_role_row(role)
self.repository.update_role(role, payload)
audit_service.write_log(
session,
{
"operate_type": "system_role_update",
"biz_type": "sys_role",
"biz_id": role.id,
"before_value": before_snapshot,
"after_value": self._build_role_row(role),
"remark": f"更新角色 {role.role_code}",
},
)
session.commit()
return {"role_id": role_id, "updated": True}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def get_role_menu_assignment(self, role_id: int, session: Session | None = None) -> dict:
"""查询角色的菜单授权信息。
参数:
role_id: 角色 ID。
session: 数据库会话,不可为 None。
返回:
包含 role_id、menu_ids、permission_codes 的字典。
被调用方system 路由(角色菜单查询接口)。
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
relations = self.repository.list_role_menu_relations(session, role_id)
menu_ids = [item.menu_id for item in relations]
menus = self.repository.list_menus_by_ids(session, menu_ids)
return {
"role_id": role_id,
"menu_ids": menu_ids,
"permission_codes": [menu.permission_code for menu in menus if menu.permission_code],
}
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def assign_role_menus(self, role_id: int, payload: dict, session: Session | None = None) -> dict:
"""为角色分配菜单权限。
支持通过 menu_ids 或 permission_codes 两种方式指定菜单,内部会合并去重。
管理员角色至少保留一个菜单。
参数:
role_id: 角色 ID。
payload: 包含 menu_ids 和/或 permission_codes 的字典。
session: 数据库会话,不可为 None。
返回:
包含 role_id、menu_count、permission_count 和 updated 标志的字典。
被调用方system 路由(角色菜单分配接口)。
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
role = self.repository.get_role(session, role_id)
if role is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在", status_code=404)
before_assignment = self.get_role_menu_assignment(role_id, session)
menu_ids = self._resolve_assign_menu_ids(session, payload)
self._validate_assign_role_menus(session, role.role_code, menu_ids)
self.repository.replace_role_menu_relations(session, role_id, menu_ids)
audit_service.write_log(
session,
{
"operate_type": "system_role_assign_menus",
"biz_type": "sys_role",
"biz_id": role.id,
"before_value": before_assignment,
"after_value": {
"role_id": role_id,
"menu_ids": menu_ids,
"permission_codes": payload.get("permission_codes", []),
},
"remark": f"更新角色授权 {role.role_code}",
},
)
session.commit()
return {
"role_id": role_id,
"menu_count": len(menu_ids),
"permission_count": len(payload.get("permission_codes", [])),
"updated": True,
}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def list_menus(self, session: Session | None = None, filters: dict | None = None) -> list[dict]:
"""查询菜单列表并构建树形结构。
参数:
session: 数据库会话,不可为 None。
filters: 可选的筛选条件。
返回:
菜单树形结构列表,每个节点包含 children 子节点。
被调用方system 路由(菜单列表接口)。
"""
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
menus = self.repository.list_menus(session, filters or {})
if menus:
return self._build_menu_tree(
[
{
"menu_id": menu.id,
"parent_id": menu.parent_id,
"menu_name": menu.menu_name,
"menu_path": menu.menu_path,
"menu_type": menu.menu_type,
"permission_code": menu.permission_code,
"icon": menu.icon,
"sort_no": menu.sort_no,
"status": menu.status,
}
for menu in menus
]
)
return []
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def create_menu(self, payload: dict, session: Session | None = None) -> dict:
"""创建菜单。
校验权限编码唯一性和父级菜单合法性后保存,同时记录审计日志。
参数:
payload: 菜单信息字典,必含 menu_name、permission_code、menu_type、parent_id 等。
session: 数据库会话,不可为 None。
返回:
包含 menu_id、menu_name、permission_code、status 的字典。
被调用方system 路由(菜单创建接口)。
"""
self._validate_menu_payload(payload)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
self._validate_menu_parent(session, payload["parent_id"], None)
existed = self.repository.get_menu_by_permission_code(session, payload["permission_code"])
if existed is not None:
raise AppException(code=ErrorCode.DUPLICATE, message="权限编码已存在", status_code=400)
menu = self.repository.create_menu(session, payload)
audit_service.write_log(
session,
{
"operate_type": "system_menu_create",
"biz_type": "sys_menu",
"biz_id": menu.id,
"before_value": None,
"after_value": self._build_menu_row(menu),
"remark": f"新增菜单 {menu.permission_code}",
},
)
session.commit()
return {
"menu_id": menu.id,
"menu_name": menu.menu_name,
"permission_code": menu.permission_code,
"status": menu.status,
}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def update_menu(self, menu_id: int, payload: dict, session: Session | None = None) -> dict:
"""更新菜单信息。
校验权限编码唯一性(排除自身)和父级菜单合法性后保存,同时记录审计日志。
参数:
menu_id: 菜单 ID。
payload: 要更新的菜单信息字典。
session: 数据库会话,不可为 None。
返回:
包含 menu_id 和 updated 标志的字典。
被调用方system 路由(菜单更新接口)。
"""
self._validate_menu_payload(payload)
if session is None:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数据库会话未初始化", status_code=500)
try:
menu = self.repository.get_menu(session, menu_id)
if menu is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="菜单不存在", status_code=404)
self._validate_menu_parent(session, payload["parent_id"], menu_id)
existed = self.repository.get_menu_by_permission_code(session, payload["permission_code"])
if existed is not None and existed.id != menu_id:
raise AppException(code=ErrorCode.DUPLICATE, message="权限编码已存在", status_code=400)
before_snapshot = self._build_menu_row(menu)
self.repository.update_menu(menu, payload)
audit_service.write_log(
session,
{
"operate_type": "system_menu_update",
"biz_type": "sys_menu",
"biz_id": menu.id,
"before_value": before_snapshot,
"after_value": self._build_menu_row(menu),
"remark": f"更新菜单 {menu.permission_code}",
},
)
session.commit()
return {"menu_id": menu_id, "updated": True}
except AppException:
session.rollback()
raise
except SQLAlchemyError as exc:
session.rollback()
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="数据库操作失败", status_code=500) from exc
def _build_user_row(self, user: object, role: object | None) -> dict:
"""将用户和角色对象组装为 API 返回用的字典。
参数:
user: 用户模型对象。
role: 角色模型对象,可能为 None。
返回:
包含用户各字段及角色名称、角色编码的字典。
"""
return {
"user_id": user.id,
"username": user.username,
"real_name": user.real_name,
"mobile": user.mobile,
"role_id": user.role_id,
"role_name": role.role_name if role else "",
"role_code": role.role_code if role else "",
"status": user.status,
"created_at": user.created_at.strftime("%Y-%m-%d %H:%M:%S") if user.created_at else "",
"updated_at": user.updated_at.strftime("%Y-%m-%d %H:%M:%S") if user.updated_at else "",
}
def _build_menu_tree(self, menu_list: list[dict]) -> list[dict]:
"""将扁平菜单列表构建为树形结构。
通过 parent_id 关联父子节点,无父级或父级不在列表中的节点作为根节点。
参数:
menu_list: 扁平的菜单字典列表,每项需包含 menu_id 和 parent_id。
返回:
根节点列表,每个节点包含 children 子节点数组。
"""
nodes = {item["menu_id"]: {**item, "children": []} for item in menu_list}
roots: list[dict] = []
for item in nodes.values():
parent_id = item["parent_id"]
if parent_id and parent_id in nodes:
nodes[parent_id]["children"].append(item)
else:
roots.append(item)
return roots
def _build_role_row(self, role: object) -> dict:
"""将角色对象转换为 API 返回用的字典。
参数:
role: 角色模型对象。
返回:
包含角色各字段的字典,日期格式化为 'YYYY-MM-DD HH:MM:SS'
"""
return {
"role_id": role.id,
"role_name": role.role_name,
"role_code": role.role_code,
"status": role.status,
"remark": role.remark,
"created_at": role.created_at.strftime("%Y-%m-%d %H:%M:%S") if getattr(role, "created_at", None) else "",
}
def _build_menu_row(self, menu: object) -> dict:
"""将菜单对象转换为 API 返回用的字典。
参数:
menu: 菜单模型对象。
返回:
包含菜单各字段的字典。
"""
return {
"menu_id": menu.id,
"parent_id": menu.parent_id,
"menu_name": menu.menu_name,
"menu_path": menu.menu_path,
"menu_type": menu.menu_type,
"permission_code": menu.permission_code,
"icon": menu.icon,
"sort_no": menu.sort_no,
"status": menu.status,
}
def _validate_create_user(self, payload: dict) -> None:
"""校验创建用户时的必填字段。
参数:
payload: 用户创建请求数据。
异常:
用户名为空或密码少于 6 位时抛出 AppException。
"""
if not payload["username"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="用户名不能为空", status_code=400)
self._validate_password(payload["password"])
self._validate_update_user(payload)
def _validate_update_user(self, payload: dict) -> None:
"""校验更新用户时的必填字段。
参数:
payload: 用户更新请求数据。
异常:
姓名为空或角色 ID 缺失时抛出 AppException。
"""
if not payload["real_name"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="姓名不能为空", status_code=400)
if not payload.get("role_id"):
raise AppException(code=ErrorCode.PARAM_ERROR, message="角色不能为空", status_code=400)
def _validate_password(self, password: str) -> None:
"""校验密码强度(最少 6 位)。
参数:
password: 密码明文。
异常:
密码少于 6 位时抛出 AppException。
"""
if len(password.strip()) < 6:
raise AppException(code=ErrorCode.PARAM_ERROR, message="密码长度不能少于6位", status_code=400)
def _validate_create_role(self, payload: dict) -> None:
"""校验创建角色时的必填字段。
参数:
payload: 角色创建请求数据。
异常:
角色名称或编码为空时抛出 AppException。
"""
if not payload["role_name"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="角色名称不能为空", status_code=400)
if not payload["role_code"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="角色编码不能为空", status_code=400)
def _validate_update_role(self, payload: dict) -> None:
"""校验更新角色时的必填字段。
参数:
payload: 角色更新请求数据。
异常:
角色名称为空时抛出 AppException。
"""
if not payload["role_name"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="角色名称不能为空", status_code=400)
def _validate_menu_payload(self, payload: dict) -> None:
"""校验菜单请求数据的必填字段和合法性。
参数:
payload: 菜单请求数据。
异常:
菜单名称、权限编码为空或菜单类型不合法时抛出 AppException。
"""
if not payload["menu_name"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="菜单名称不能为空", status_code=400)
if not payload["permission_code"].strip():
raise AppException(code=ErrorCode.PARAM_ERROR, message="权限编码不能为空", status_code=400)
if payload["menu_type"] not in self.VALID_MENU_TYPES:
raise AppException(code=ErrorCode.PARAM_ERROR, message="菜单类型不合法", status_code=400)
def _validate_menu_parent(self, session: Session, parent_id: int, menu_id: int | None) -> None:
"""校验父级菜单的合法性。
检查父级是否存在、不能指向自身,并在更新时检测循环引用。
参数:
session: 数据库会话。
parent_id: 父级菜单 ID。
menu_id: 当前菜单 ID创建时为 None
"""
if not parent_id:
return
if menu_id is not None and parent_id == menu_id:
raise AppException(code=ErrorCode.BUSINESS_RULE_FAILED, message="父级菜单不能是自身", status_code=400)
parent_menu = self.repository.get_menu(session, parent_id)
if parent_menu is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="父级菜单不存在", status_code=404)
if menu_id is not None:
self._ensure_menu_parent_not_cyclic(session, parent_menu.id, menu_id)
def _ensure_menu_parent_not_cyclic(self, session: Session, parent_id: int, menu_id: int) -> None:
"""沿父级链向上检查,防止菜单层级出现循环引用。
参数:
session: 数据库会话。
parent_id: 父级菜单 ID从此节点向上遍历。
menu_id: 当前菜单 ID遍历中若遇到则说明存在循环。
异常:
检测到循环引用时抛出 AppExceptionBUSINESS_RULE_FAILED
"""
current_parent_id = parent_id
visited_ids: set[int] = set()
# 沿父级链一路向上检查,既要拦住“挂到自己的子级上”,也要拦住异常数据导致的循环链路。
while current_parent_id:
if current_parent_id == menu_id:
raise AppException(code=ErrorCode.BUSINESS_RULE_FAILED, message="父级菜单不能设置为自身或子级", status_code=400)
if current_parent_id in visited_ids:
raise AppException(code=ErrorCode.BUSINESS_RULE_FAILED, message="菜单层级存在循环引用", status_code=400)
visited_ids.add(current_parent_id)
current_menu = self.repository.get_menu(session, current_parent_id)
if current_menu is None:
break
current_parent_id = current_menu.parent_id
def _resolve_assign_menu_ids(self, session: Session, payload: dict) -> list[int]:
"""将请求中的 menu_ids 和 permission_codes 合并为去重的菜单 ID 列表。
参数:
session: 数据库会话。
payload: 包含 menu_ids 和/或 permission_codes 的字典。
返回:
排序后的菜单 ID 列表。
"""
menu_ids = {int(menu_id) for menu_id in payload.get("menu_ids", [])}
permission_codes = {item.strip() for item in payload.get("permission_codes", []) if item.strip()}
if permission_codes:
menus = self.repository.list_menus(session, {"status": 1})
for menu in menus:
if menu.permission_code in permission_codes:
menu_ids.add(menu.id)
return sorted(menu_ids)
def _validate_assign_role_menus(self, session: Session, role_code: str, menu_ids: list[int]) -> None:
"""校验角色菜单授权数据的合法性。
检查菜单是否存在且启用,管理员角色至少保留一个菜单。
参数:
session: 数据库会话。
role_code: 角色编码。
menu_ids: 待分配的菜单 ID 列表。
"""
menus = self.repository.list_menus_by_ids(session, menu_ids)
if len(menus) != len(menu_ids):
raise AppException(code=ErrorCode.NOT_FOUND, message="菜单不存在", status_code=404)
for menu in menus:
if menu.status != 1:
raise AppException(code=ErrorCode.NOT_FOUND, message="菜单不存在或已停用", status_code=404)
if role_code == "admin" and not menu_ids:
raise AppException(code=ErrorCode.BUSINESS_RULE_FAILED, message="管理员角色至少保留一个权限菜单", status_code=400)
system_service = SystemService()