2026-05-30 07:23:33 +08:00
|
|
|
|
"""系统管理服务模块
|
|
|
|
|
|
|
|
|
|
|
|
负责后台系统管理的核心业务逻辑,包括:
|
|
|
|
|
|
- 用户管理(增删改查、密码重置、启用/停用)
|
|
|
|
|
|
- 角色管理(增删改查、启用/停用)
|
|
|
|
|
|
- 菜单与权限管理(增删改查、角色菜单授权、菜单树构建)
|
|
|
|
|
|
|
|
|
|
|
|
所有写操作均会记录审计日志。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(用户、角色、菜单相关的全部管理接口)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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
|
2026-05-15 12:05:18 +08:00
|
|
|
|
from backend.app.core.security import hash_password
|
2026-05-14 23:28:09 +08:00
|
|
|
|
from backend.app.repositories.system_repository import SystemRepository
|
2026-05-15 12:05:18 +08:00
|
|
|
|
from backend.app.services.audit_service import audit_service
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SystemService:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""系统管理业务服务。
|
|
|
|
|
|
|
|
|
|
|
|
依赖:
|
|
|
|
|
|
- SystemRepository:用户、角色、菜单的数据库操作。
|
|
|
|
|
|
- AuditService:操作审计日志记录。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""查询系统用户列表。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
filters: 可选的筛选条件。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 total、page_no、page_size、list 的分页结果字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(用户列表接口)。
|
|
|
|
|
|
"""
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def create_user(self, payload: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""创建系统用户。
|
|
|
|
|
|
|
|
|
|
|
|
校验用户名唯一性和角色有效性,对密码做哈希处理后保存,同时记录审计日志。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 用户信息字典,必含 username、password、real_name、role_id 等。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 user_id、username 等字段的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(用户创建接口)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
self._validate_create_user(payload)
|
|
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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)
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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)
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def update_user(self, user_id: int, payload: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""更新系统用户信息。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
user_id: 用户 ID。
|
|
|
|
|
|
payload: 要更新的用户信息字典。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 user_id 和 updated 标志的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(用户更新接口)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
self._validate_update_user(payload)
|
|
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def reset_password(self, user_id: int, new_password: str, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""重置用户密码。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
user_id: 用户 ID。
|
|
|
|
|
|
new_password: 新密码明文(不少于 6 位)。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 user_id 和 reset 标志的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(密码重置接口)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
self._validate_password(new_password)
|
|
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
2026-05-28 22:59:04 +08:00
|
|
|
|
def change_user_status(self, user_id: int, status: int, session: Session | None = None, current_user: dict | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""启用或停用用户。
|
|
|
|
|
|
|
|
|
|
|
|
包含安全校验:不能修改自己的状态,不能停用最后一个管理员。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
user_id: 用户 ID。
|
|
|
|
|
|
status: 目标状态,0=停用,1=启用。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
current_user: 当前登录用户信息,用于防止自操作。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 user_id 和 status 的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(用户状态变更接口)。
|
|
|
|
|
|
"""
|
2026-05-28 22:59:04 +08:00
|
|
|
|
if status not in (0, 1):
|
|
|
|
|
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="状态值必须为 0 或 1", status_code=400)
|
|
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-28 22:59:04 +08:00
|
|
|
|
|
2026-05-14 23:28:09 +08:00
|
|
|
|
def list_roles(self, session: Session | None = None, filters: dict | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""查询角色列表。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
filters: 可选的筛选条件。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 total、page_no、page_size、list 的分页结果字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(角色列表接口)。
|
|
|
|
|
|
"""
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def create_role(self, payload: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""创建角色。
|
|
|
|
|
|
|
|
|
|
|
|
校验角色编码唯一性后保存,同时记录审计日志。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 角色信息字典,必含 role_name、role_code。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 role_id、role_name、role_code、status 的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(角色创建接口)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
self._validate_create_role(payload)
|
|
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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)
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def update_role(self, role_id: int, payload: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""更新角色信息。
|
|
|
|
|
|
|
|
|
|
|
|
包含业务校验:管理员角色不能停用,停用时不能有启用的用户。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
role_id: 角色 ID。
|
|
|
|
|
|
payload: 要更新的角色信息字典。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 role_id 和 updated 标志的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(角色更新接口)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
self._validate_update_role(payload)
|
|
|
|
|
|
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
2026-05-15 09:00:50 +08:00
|
|
|
|
def get_role_menu_assignment(self, role_id: int, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""查询角色的菜单授权信息。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
role_id: 角色 ID。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 role_id、menu_ids、permission_codes 的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(角色菜单查询接口)。
|
|
|
|
|
|
"""
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-15 09:00:50 +08:00
|
|
|
|
|
|
|
|
|
|
def assign_role_menus(self, role_id: int, payload: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""为角色分配菜单权限。
|
|
|
|
|
|
|
|
|
|
|
|
支持通过 menu_ids 或 permission_codes 两种方式指定菜单,内部会合并去重。
|
|
|
|
|
|
管理员角色至少保留一个菜单。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
role_id: 角色 ID。
|
|
|
|
|
|
payload: 包含 menu_ids 和/或 permission_codes 的字典。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 role_id、menu_count、permission_count 和 updated 标志的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(角色菜单分配接口)。
|
|
|
|
|
|
"""
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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", []),
|
2026-05-15 12:05:18 +08:00
|
|
|
|
},
|
2026-05-29 11:38:53 +08:00
|
|
|
|
"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
|
2026-05-15 09:00:50 +08:00
|
|
|
|
|
2026-05-14 23:28:09 +08:00
|
|
|
|
def list_menus(self, session: Session | None = None, filters: dict | None = None) -> list[dict]:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""查询菜单列表并构建树形结构。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
filters: 可选的筛选条件。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
菜单树形结构列表,每个节点包含 children 子节点。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(菜单列表接口)。
|
|
|
|
|
|
"""
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def create_menu(self, payload: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""创建菜单。
|
|
|
|
|
|
|
|
|
|
|
|
校验权限编码唯一性和父级菜单合法性后保存,同时记录审计日志。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 菜单信息字典,必含 menu_name、permission_code、menu_type、parent_id 等。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 menu_id、menu_name、permission_code、status 的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(菜单创建接口)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
self._validate_menu_payload(payload)
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def update_menu(self, menu_id: int, payload: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""更新菜单信息。
|
|
|
|
|
|
|
|
|
|
|
|
校验权限编码唯一性(排除自身)和父级菜单合法性后保存,同时记录审计日志。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
menu_id: 菜单 ID。
|
|
|
|
|
|
payload: 要更新的菜单信息字典。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 menu_id 和 updated 标志的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:system 路由(菜单更新接口)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
self._validate_menu_payload(payload)
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
def _build_user_row(self, user: object, role: object | None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将用户和角色对象组装为 API 返回用的字典。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
user: 用户模型对象。
|
|
|
|
|
|
role: 角色模型对象,可能为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含用户各字段及角色名称、角色编码的字典。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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]:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将扁平菜单列表构建为树形结构。
|
|
|
|
|
|
|
|
|
|
|
|
通过 parent_id 关联父子节点,无父级或父级不在列表中的节点作为根节点。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
menu_list: 扁平的菜单字典列表,每项需包含 menu_id 和 parent_id。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
根节点列表,每个节点包含 children 子节点数组。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-15 12:05:18 +08:00
|
|
|
|
def _build_role_row(self, role: object) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将角色对象转换为 API 返回用的字典。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
role: 角色模型对象。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含角色各字段的字典,日期格式化为 'YYYY-MM-DD HH:MM:SS'。
|
|
|
|
|
|
"""
|
2026-05-15 12:05:18 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将菜单对象转换为 API 返回用的字典。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
menu: 菜单模型对象。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含菜单各字段的字典。
|
|
|
|
|
|
"""
|
2026-05-15 12:05:18 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 23:28:09 +08:00
|
|
|
|
def _validate_create_user(self, payload: dict) -> None:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验创建用户时的必填字段。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 用户创建请求数据。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
用户名为空或密码少于 6 位时抛出 AppException。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验更新用户时的必填字段。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 用户更新请求数据。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
姓名为空或角色 ID 缺失时抛出 AppException。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验密码强度(最少 6 位)。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
password: 密码明文。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
密码少于 6 位时抛出 AppException。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
if len(password.strip()) < 6:
|
|
|
|
|
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="密码长度不能少于6位", status_code=400)
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_create_role(self, payload: dict) -> None:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验创建角色时的必填字段。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 角色创建请求数据。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
角色名称或编码为空时抛出 AppException。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验更新角色时的必填字段。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 角色更新请求数据。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
角色名称为空时抛出 AppException。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
if not payload["role_name"].strip():
|
|
|
|
|
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="角色名称不能为空", status_code=400)
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_menu_payload(self, payload: dict) -> None:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验菜单请求数据的必填字段和合法性。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
payload: 菜单请求数据。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
菜单名称、权限编码为空或菜单类型不合法时抛出 AppException。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验父级菜单的合法性。
|
|
|
|
|
|
|
|
|
|
|
|
检查父级是否存在、不能指向自身,并在更新时检测循环引用。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
session: 数据库会话。
|
|
|
|
|
|
parent_id: 父级菜单 ID。
|
|
|
|
|
|
menu_id: 当前菜单 ID(创建时为 None)。
|
|
|
|
|
|
"""
|
2026-05-14 23:28:09 +08:00
|
|
|
|
if not parent_id:
|
|
|
|
|
|
return
|
|
|
|
|
|
if menu_id is not None and parent_id == menu_id:
|
2026-05-15 14:17:20 +08:00
|
|
|
|
raise AppException(code=ErrorCode.BUSINESS_RULE_FAILED, message="父级菜单不能是自身", status_code=400)
|
|
|
|
|
|
|
2026-05-14 23:28:09 +08:00
|
|
|
|
parent_menu = self.repository.get_menu(session, parent_id)
|
|
|
|
|
|
if parent_menu is None:
|
|
|
|
|
|
raise AppException(code=ErrorCode.NOT_FOUND, message="父级菜单不存在", status_code=404)
|
|
|
|
|
|
|
2026-05-15 14:17:20 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""沿父级链向上检查,防止菜单层级出现循环引用。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
session: 数据库会话。
|
|
|
|
|
|
parent_id: 父级菜单 ID,从此节点向上遍历。
|
|
|
|
|
|
menu_id: 当前菜单 ID,遍历中若遇到则说明存在循环。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
检测到循环引用时抛出 AppException(BUSINESS_RULE_FAILED)。
|
|
|
|
|
|
"""
|
2026-05-15 14:17:20 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-15 09:00:50 +08:00
|
|
|
|
def _resolve_assign_menu_ids(self, session: Session, payload: dict) -> list[int]:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将请求中的 menu_ids 和 permission_codes 合并为去重的菜单 ID 列表。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
session: 数据库会话。
|
|
|
|
|
|
payload: 包含 menu_ids 和/或 permission_codes 的字典。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
排序后的菜单 ID 列表。
|
|
|
|
|
|
"""
|
2026-05-15 09:00:50 +08:00
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验角色菜单授权数据的合法性。
|
|
|
|
|
|
|
|
|
|
|
|
检查菜单是否存在且启用,管理员角色至少保留一个菜单。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
session: 数据库会话。
|
|
|
|
|
|
role_code: 角色编码。
|
|
|
|
|
|
menu_ids: 待分配的菜单 ID 列表。
|
|
|
|
|
|
"""
|
2026-05-15 09:00:50 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-14 23:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
system_service = SystemService()
|