2026-06-14 16:49:18 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微IT智能服务台 — 管理后台角色管理 API
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 说明:管理后台的角色管理接口
|
|
|
|
|
|
# 包含:
|
|
|
|
|
|
# 1. 角色管理(CRUD)
|
|
|
|
|
|
# 2. 用户角色分配/撤销
|
|
|
|
|
|
# 3. 角色映射规则管理
|
|
|
|
|
|
# 所有接口需要 admin 角色权限
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
|
2026-07-09 13:46:15 +08:00
|
|
|
|
import redis.asyncio as aioredis
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
|
|
from sqlalchemy import select, func
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-07-09 13:46:15 +08:00
|
|
|
|
from app.dependencies import get_current_user, UserInfo, require_role, dep_redis
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from app.database import get_db
|
2026-07-09 13:46:15 +08:00
|
|
|
|
from app.models.employee import Employee
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from app.models.role import Role
|
|
|
|
|
|
from app.models.role_mapping_rule import RoleMappingRule
|
|
|
|
|
|
from app.models.user_role import UserRole
|
|
|
|
|
|
from app.schemas.role import (
|
|
|
|
|
|
RoleAssignRequest,
|
|
|
|
|
|
RoleMappingRuleRequest,
|
|
|
|
|
|
RoleMappingRuleResponse,
|
|
|
|
|
|
RoleRevokeRequest,
|
|
|
|
|
|
RoleResponse,
|
|
|
|
|
|
UserRoleResponse,
|
|
|
|
|
|
)
|
2026-07-09 13:46:15 +08:00
|
|
|
|
from app.services.employee_directory import get_org_directory, resolve_target
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from app.utils.response import AppException, success_response
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mask_sensitive_data(value: str, visible_chars: int = 3) -> str:
|
|
|
|
|
|
"""脱敏处理敏感数据。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
value: 原始值
|
|
|
|
|
|
visible_chars: 开头保留的字符数
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: 脱敏后的值,如 "abc***def"
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if len(value) <= visible_chars:
|
|
|
|
|
|
return "*" * len(value)
|
|
|
|
|
|
return f"{value[:visible_chars]}{'*' * (len(value) - visible_chars)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 创建路由器
|
|
|
|
|
|
router = APIRouter(prefix="/admin/roles")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 管理后台权限校验依赖
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def require_admin(
|
|
|
|
|
|
current_user: UserInfo = Depends(get_current_user),
|
|
|
|
|
|
) -> UserInfo:
|
|
|
|
|
|
"""管理后台权限校验:仅 admin 角色可访问。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
current_user: 当前用户(通过认证依赖注入)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
UserInfo: 具有管理权限的用户信息
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
AppException: 非管理员角色(错误码 1004)
|
|
|
|
|
|
"""
|
|
|
|
|
|
if "admin" not in current_user.roles:
|
|
|
|
|
|
raise AppException(1004, "无管理权限")
|
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 1. 角色管理
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- GET /api/admin/roles ----------
|
|
|
|
|
|
@router.get("")
|
|
|
|
|
|
async def get_roles(
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取所有角色列表。
|
|
|
|
|
|
|
|
|
|
|
|
返回角色列表,包含每个角色的用户数量统计。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,包含角色列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 查询所有角色
|
|
|
|
|
|
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
roles = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
# 构建响应,包含用户数量
|
|
|
|
|
|
role_list = []
|
|
|
|
|
|
for role in roles:
|
|
|
|
|
|
# 统计拥有该角色的用户数
|
|
|
|
|
|
count_stmt = select(func.count()).select_from(UserRole).where(UserRole.role_id == role.id)
|
|
|
|
|
|
count_result = await db.execute(count_stmt)
|
|
|
|
|
|
user_count = count_result.scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
role_list.append(
|
|
|
|
|
|
RoleResponse(
|
|
|
|
|
|
id=role.id,
|
|
|
|
|
|
name=role.name,
|
|
|
|
|
|
display_name=role.display_name,
|
|
|
|
|
|
description=role.description,
|
|
|
|
|
|
permissions=role.permissions or [],
|
|
|
|
|
|
is_default=role.is_default,
|
|
|
|
|
|
user_count=user_count,
|
|
|
|
|
|
created_at=role.created_at,
|
|
|
|
|
|
updated_at=role.updated_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data=[r.model_dump() for r in role_list])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 21:54:57 +08:00
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 1.5. 用户角色分配列表
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- GET /api/admin/roles/user-roles ----------
|
|
|
|
|
|
@router.get("/user-roles")
|
|
|
|
|
|
async def list_user_role_assignments(
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取所有用户角色分配记录。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of user role assignments with employee_id, role info, source, etc.
|
|
|
|
|
|
"""
|
2026-07-09 11:47:16 +08:00
|
|
|
|
# 使用 LEFT OUTER JOIN:即使 user_roles.role_id 在 roles 表中已不存在
|
|
|
|
|
|
# (例如角色被重建导致 UUID 变化),也保留该条分配记录,避免已分配用户被静默隐藏。
|
2026-07-09 13:46:15 +08:00
|
|
|
|
# 同时 LEFT JOIN employees 表获取员工姓名。
|
2026-07-09 11:47:16 +08:00
|
|
|
|
# assigned_at 定义为 NOT NULL,nulls_last() 无意义,直接降序即可(SQLite/PG 通用)。
|
2026-07-08 21:54:57 +08:00
|
|
|
|
stmt = (
|
2026-07-09 13:46:15 +08:00
|
|
|
|
select(UserRole, Role, Employee)
|
2026-07-09 11:47:16 +08:00
|
|
|
|
.outerjoin(Role, UserRole.role_id == Role.id)
|
2026-07-09 13:46:15 +08:00
|
|
|
|
.outerjoin(Employee, UserRole.employee_id == Employee.employee_id)
|
2026-07-09 11:47:16 +08:00
|
|
|
|
.order_by(UserRole.assigned_at.desc())
|
2026-07-08 21:54:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
rows = result.all()
|
2026-07-09 11:47:16 +08:00
|
|
|
|
|
2026-07-08 21:54:57 +08:00
|
|
|
|
assignments = []
|
2026-07-09 13:46:15 +08:00
|
|
|
|
for user_role, role, employee in rows:
|
2026-07-09 11:47:16 +08:00
|
|
|
|
# role 可能为 None(孤儿记录),做兜底展示,而不是丢弃该用户
|
|
|
|
|
|
role_name = role.name if role else "unknown"
|
|
|
|
|
|
role_display = (role.display_name or role.name) if role else "未知角色"
|
2026-07-09 13:46:15 +08:00
|
|
|
|
# employee 可能为 None(员工已从组织架构移除)
|
|
|
|
|
|
employee_name = employee.name if employee else ""
|
2026-07-08 21:54:57 +08:00
|
|
|
|
assignments.append({
|
|
|
|
|
|
"employee_id": user_role.employee_id,
|
2026-07-09 13:46:15 +08:00
|
|
|
|
"employee_name": employee_name,
|
2026-07-09 11:47:16 +08:00
|
|
|
|
"role_name": role_name,
|
|
|
|
|
|
"role_display_name": role_display,
|
2026-07-08 21:54:57 +08:00
|
|
|
|
"source": user_role.source or "manual",
|
|
|
|
|
|
"assigned_by": user_role.assigned_by or "",
|
|
|
|
|
|
"assigned_at": user_role.assigned_at.isoformat() if user_role.assigned_at else None,
|
|
|
|
|
|
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
|
|
|
|
|
|
})
|
2026-07-09 11:47:16 +08:00
|
|
|
|
|
2026-07-08 21:54:57 +08:00
|
|
|
|
return success_response(data=assignments)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 2. 用户角色分配/撤销
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- POST /api/admin/roles/assign ----------
|
|
|
|
|
|
@router.post("/assign")
|
|
|
|
|
|
async def assign_role(
|
|
|
|
|
|
body: RoleAssignRequest,
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2026-07-09 13:46:15 +08:00
|
|
|
|
redis: aioredis.Redis = Depends(dep_redis),
|
2026-06-14 16:49:18 +08:00
|
|
|
|
):
|
|
|
|
|
|
"""手动分配角色。
|
|
|
|
|
|
|
|
|
|
|
|
为指定用户分配角色,记录分配者和分配原因。
|
2026-07-09 13:46:15 +08:00
|
|
|
|
支持输入「员工账号 或 姓名」:
|
|
|
|
|
|
- 先经 employee_directory.resolve_target 解析为企微 UserID;
|
|
|
|
|
|
- 再用企微通讯录实时校验该员工确实属于企微组织架构(不在组织内则拒绝)。
|
2026-06-14 16:49:18 +08:00
|
|
|
|
安全限制:禁止管理员给自己分配角色。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-07-09 13:46:15 +08:00
|
|
|
|
body: 分配角色请求(target=账号/姓名,或兼容 employee_id)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
2026-07-09 13:46:15 +08:00
|
|
|
|
redis: Redis 客户端(组织目录缓存用)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式
|
|
|
|
|
|
"""
|
2026-07-09 13:46:15 +08:00
|
|
|
|
# 取待解析的目标(优先 target,兼容旧 employee_id)
|
|
|
|
|
|
raw = (body.target or body.employee_id or "").strip()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
raise AppException(4001, "请输入员工账号或姓名")
|
|
|
|
|
|
|
|
|
|
|
|
# 解析 + 实时校验:是否为企微组织架构内的真实员工
|
|
|
|
|
|
resolved = await resolve_target(raw, db, redis)
|
|
|
|
|
|
if resolved.get("ambiguous"):
|
|
|
|
|
|
cands = resolved["candidates"]
|
|
|
|
|
|
names = "、".join(f"{c['name']}({c['employee_id']})" for c in cands[:5])
|
|
|
|
|
|
raise AppException(
|
|
|
|
|
|
4016,
|
|
|
|
|
|
f"匹配到多名员工,请改用员工账号精确分配:{names}",
|
|
|
|
|
|
)
|
|
|
|
|
|
if not resolved.get("found"):
|
|
|
|
|
|
raise AppException(4017, resolved.get("reason", "员工不存在"))
|
|
|
|
|
|
|
|
|
|
|
|
employee_id = resolved["employee_id"]
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# 安全限制:禁止管理员给自己分配角色
|
2026-07-09 13:46:15 +08:00
|
|
|
|
if admin.employee_id and employee_id == admin.employee_id:
|
2026-06-14 16:49:18 +08:00
|
|
|
|
raise AppException(4014, "不能给自己分配角色")
|
|
|
|
|
|
|
|
|
|
|
|
# 查询目标角色
|
|
|
|
|
|
role_stmt = select(Role).where(Role.name == body.role_name)
|
|
|
|
|
|
role_result = await db.execute(role_stmt)
|
|
|
|
|
|
role = role_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if not role:
|
|
|
|
|
|
raise AppException(4004, f"角色 {body.role_name} 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已拥有该角色
|
|
|
|
|
|
existing_stmt = select(UserRole).where(
|
2026-07-09 13:46:15 +08:00
|
|
|
|
UserRole.employee_id == employee_id,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
UserRole.role_id == role.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_result = await db.execute(existing_stmt)
|
|
|
|
|
|
existing = existing_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
raise AppException(4009, f"用户已拥有 {body.role_name} 角色")
|
|
|
|
|
|
|
|
|
|
|
|
# 创建用户角色关联
|
|
|
|
|
|
user_role = UserRole(
|
2026-07-09 13:46:15 +08:00
|
|
|
|
employee_id=employee_id,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
role_id=role.id,
|
|
|
|
|
|
source="manual",
|
|
|
|
|
|
assigned_by=admin.employee_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(user_role)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
2026-07-09 13:46:15 +08:00
|
|
|
|
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
return success_response(message=f"角色 {body.role_name} 分配成功")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 13:46:15 +08:00
|
|
|
|
# ---------- GET /api/admin/roles/employees/search ----------
|
|
|
|
|
|
@router.get("/employees/search")
|
|
|
|
|
|
async def search_employees(
|
|
|
|
|
|
q: str = Query("", description="姓名或员工账号关键字"),
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
redis: aioredis.Redis = Depends(dep_redis),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""员工目录搜索(分配角色时自动补全用)。
|
|
|
|
|
|
|
|
|
|
|
|
返回匹配关键字的员工候选(账号 + 姓名 + 部门),并在 full_directory 字段
|
|
|
|
|
|
标明当前目录是否覆盖全公司(True=企微全组织;False=仅已登录员工,需开通
|
|
|
|
|
|
通讯录读取权限才能搜索全公司)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
q: 搜索关键字(姓名或账号,至少 1 个字符)
|
|
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
redis: Redis 客户端
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: { items: [...], full_directory: bool|None }
|
|
|
|
|
|
"""
|
|
|
|
|
|
q = (q or "").strip()
|
|
|
|
|
|
if len(q) < 1:
|
|
|
|
|
|
return success_response(data={"items": [], "full_directory": None})
|
|
|
|
|
|
|
|
|
|
|
|
directory, full = await get_org_directory(db, redis)
|
|
|
|
|
|
ql = q.lower()
|
|
|
|
|
|
hits = [
|
|
|
|
|
|
m for m in directory
|
|
|
|
|
|
if ql in (m["employee_id"] or "").lower()
|
|
|
|
|
|
or ql in (m["name"] or "").lower()
|
|
|
|
|
|
]
|
|
|
|
|
|
# 优先精确账号命中,其次姓名包含
|
|
|
|
|
|
items = hits[:20]
|
|
|
|
|
|
return success_response(data={"items": items, "full_directory": full})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# ---------- POST /api/admin/roles/revoke ----------
|
|
|
|
|
|
@router.post("/revoke")
|
|
|
|
|
|
async def revoke_role(
|
|
|
|
|
|
body: RoleRevokeRequest,
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""撤销角色。
|
|
|
|
|
|
|
|
|
|
|
|
撤销指定用户的角色。
|
|
|
|
|
|
安全限制:禁止管理员撤销自己的角色。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
body: 撤销角色请求
|
|
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 安全限制:禁止管理员撤销自己的角色
|
|
|
|
|
|
if body.employee_id == admin.employee_id:
|
|
|
|
|
|
raise AppException(4015, "不能撤销自己的角色")
|
|
|
|
|
|
|
|
|
|
|
|
# 查询目标角色
|
|
|
|
|
|
role_stmt = select(Role).where(Role.name == body.role_name)
|
|
|
|
|
|
role_result = await db.execute(role_stmt)
|
|
|
|
|
|
role = role_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if not role:
|
|
|
|
|
|
raise AppException(4004, f"角色 {body.role_name} 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
# 不允许撤销默认角色
|
|
|
|
|
|
if role.is_default:
|
|
|
|
|
|
raise AppException(4010, "不能撤销默认角色")
|
|
|
|
|
|
|
|
|
|
|
|
# 查询用户角色关联
|
|
|
|
|
|
user_role_stmt = select(UserRole).where(
|
|
|
|
|
|
UserRole.employee_id == body.employee_id,
|
|
|
|
|
|
UserRole.role_id == role.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
user_role_result = await db.execute(user_role_stmt)
|
|
|
|
|
|
user_role = user_role_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if not user_role:
|
|
|
|
|
|
raise AppException(4011, f"用户没有 {body.role_name} 角色")
|
|
|
|
|
|
|
|
|
|
|
|
# 删除用户角色关联
|
|
|
|
|
|
await db.delete(user_role)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 撤销用户 {_mask_sensitive_data(body.employee_id)} 的角色 {body.role_name},原因:{body.reason}")
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(message=f"角色 {body.role_name} 撤销成功")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 3. 角色映射规则管理
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- GET /api/admin/roles/mapping-rules ----------
|
|
|
|
|
|
@router.get("/mapping-rules")
|
|
|
|
|
|
async def get_mapping_rules(
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取所有角色映射规则。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,包含映射规则列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 查询所有映射规则
|
|
|
|
|
|
stmt = (
|
|
|
|
|
|
select(RoleMappingRule, Role)
|
|
|
|
|
|
.join(Role, RoleMappingRule.role_id == Role.id)
|
|
|
|
|
|
.order_by(RoleMappingRule.priority.desc(), RoleMappingRule.source_type)
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
rules = result.all()
|
|
|
|
|
|
|
|
|
|
|
|
# 构建响应
|
|
|
|
|
|
rule_list = []
|
|
|
|
|
|
for rule, role in rules:
|
|
|
|
|
|
rule_list.append(
|
|
|
|
|
|
RoleMappingRuleResponse(
|
|
|
|
|
|
id=rule.id,
|
|
|
|
|
|
role_id=rule.role_id,
|
|
|
|
|
|
role_name=role.name,
|
|
|
|
|
|
source_type=rule.source_type,
|
|
|
|
|
|
source_value=rule.source_value,
|
|
|
|
|
|
priority=rule.priority,
|
|
|
|
|
|
is_active=rule.is_active,
|
|
|
|
|
|
created_at=rule.created_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data=[r.model_dump() for r in rule_list])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- POST /api/admin/roles/mapping-rules ----------
|
|
|
|
|
|
@router.post("/mapping-rules")
|
|
|
|
|
|
async def create_mapping_rule(
|
|
|
|
|
|
body: RoleMappingRuleRequest,
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""创建角色映射规则。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
body: 创建映射规则请求
|
|
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,包含新创建的规则 ID
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 查询目标角色
|
|
|
|
|
|
role_stmt = select(Role).where(Role.name == body.role_name)
|
|
|
|
|
|
role_result = await db.execute(role_stmt)
|
|
|
|
|
|
role = role_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if not role:
|
|
|
|
|
|
raise AppException(4004, f"角色 {body.role_name} 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已存在相同的规则
|
|
|
|
|
|
existing_stmt = select(RoleMappingRule).where(
|
|
|
|
|
|
RoleMappingRule.role_id == role.id,
|
|
|
|
|
|
RoleMappingRule.source_type == body.source_type,
|
|
|
|
|
|
RoleMappingRule.source_value == body.source_value,
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_result = await db.execute(existing_stmt)
|
|
|
|
|
|
existing = existing_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
raise AppException(4012, "已存在相同的映射规则")
|
|
|
|
|
|
|
|
|
|
|
|
# 创建映射规则
|
|
|
|
|
|
rule = RoleMappingRule(
|
|
|
|
|
|
role_id=role.id,
|
|
|
|
|
|
source_type=body.source_type,
|
|
|
|
|
|
source_value=body.source_value,
|
|
|
|
|
|
priority=body.priority,
|
|
|
|
|
|
is_active=body.is_active,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(rule)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 创建映射规则:{body.source_type}={body.source_value} → {body.role_name}")
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
message="映射规则创建成功",
|
|
|
|
|
|
data={"id": rule.id},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- DELETE /api/admin/roles/mapping-rules/{rule_id} ----------
|
|
|
|
|
|
@router.delete("/mapping-rules/{rule_id}")
|
|
|
|
|
|
async def delete_mapping_rule(
|
|
|
|
|
|
rule_id: str,
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""删除角色映射规则。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
rule_id: 规则 ID
|
|
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 查询规则
|
|
|
|
|
|
rule_stmt = select(RoleMappingRule).where(RoleMappingRule.id == rule_id)
|
|
|
|
|
|
rule_result = await db.execute(rule_stmt)
|
|
|
|
|
|
rule = rule_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if not rule:
|
|
|
|
|
|
raise AppException(4013, "映射规则不存在")
|
|
|
|
|
|
|
|
|
|
|
|
# 删除规则
|
|
|
|
|
|
await db.delete(rule)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(message="映射规则删除成功")
|
2026-06-22 17:38:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 4. 权限矩阵可视化 (v0.7.1 task #86)
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 给管理后台 UI 用: 返回 5 角色 × 4 资源 × 4 操作 × 3 范围的完整矩阵
|
|
|
|
|
|
# 嵌套结构方便前端直接渲染表格:
|
|
|
|
|
|
# {
|
|
|
|
|
|
# "roles": [{name, display_name, permissions: [string]}],
|
|
|
|
|
|
# "resources": [conversation, agent, ...],
|
|
|
|
|
|
# "actions": [read, create, update, delete],
|
|
|
|
|
|
# "scopes": [own, department, all],
|
|
|
|
|
|
# "matrix": {
|
|
|
|
|
|
# "agent": { # 角色名
|
|
|
|
|
|
# "conversation:read:own": true,
|
|
|
|
|
|
# "conversation:read:all": true,
|
|
|
|
|
|
# ...
|
|
|
|
|
|
# }
|
|
|
|
|
|
# }
|
|
|
|
|
|
# }
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
@router.get("/permissions/matrix")
|
|
|
|
|
|
async def get_permissions_matrix(
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取 RBAC 完整权限矩阵(管理后台可视化用)。
|
|
|
|
|
|
|
|
|
|
|
|
返回 5 角色预置的 permissions JSON,前端用此数据渲染
|
|
|
|
|
|
角色 × 资源 × 操作 × 范围 的可读表格。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
admin: 管理员(权限校验)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,包含完整权限矩阵
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.rbac_service import (
|
|
|
|
|
|
ROLE_PERMISSIONS,
|
|
|
|
|
|
VALID_ACTIONS,
|
|
|
|
|
|
VALID_RESOURCES,
|
|
|
|
|
|
VALID_SCOPES,
|
|
|
|
|
|
permissions_to_strings,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 查 DB 拿角色元数据(显示名等)
|
|
|
|
|
|
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
roles = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 构建角色列表(以代码里的 ROLE_PERMISSIONS 为准,DB 字段作 display_name)
|
|
|
|
|
|
role_list = []
|
|
|
|
|
|
matrix = {}
|
|
|
|
|
|
for role in roles:
|
|
|
|
|
|
# 优先用代码常量(单一可信源);DB 字段仅作元数据
|
|
|
|
|
|
perms = ROLE_PERMISSIONS.get(role.name, set())
|
|
|
|
|
|
perms_list = permissions_to_strings(perms)
|
|
|
|
|
|
|
|
|
|
|
|
role_list.append({
|
|
|
|
|
|
"name": role.name,
|
|
|
|
|
|
"display_name": role.display_name,
|
|
|
|
|
|
"description": role.description,
|
|
|
|
|
|
"is_default": role.is_default,
|
|
|
|
|
|
"permission_count": len(perms_list),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 角色 × 资源 × 操作 × 范围 的全矩阵
|
|
|
|
|
|
# true/false 表征是否拥有此权限
|
|
|
|
|
|
# 前端用此渲染表格,空格表示"不适用"
|
|
|
|
|
|
role_matrix = {}
|
|
|
|
|
|
for resource in VALID_RESOURCES:
|
|
|
|
|
|
for action in VALID_ACTIONS:
|
|
|
|
|
|
for scope in VALID_SCOPES:
|
|
|
|
|
|
perm = f"{resource}:{action}:{scope}"
|
|
|
|
|
|
role_matrix[perm] = (resource, action, scope) in perms
|
|
|
|
|
|
matrix[role.name] = role_matrix
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"roles": role_list,
|
|
|
|
|
|
"resources": VALID_RESOURCES,
|
|
|
|
|
|
"actions": VALID_ACTIONS,
|
|
|
|
|
|
"scopes": VALID_SCOPES,
|
|
|
|
|
|
"matrix": matrix,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- GET /api/admin/roles/permissions/check ----------
|
|
|
|
|
|
# 给前端按钮级权限控制用: 传入 (resource, action, scope) 查当前用户是否拥有
|
|
|
|
|
|
# 注: 这是 endpoint 版本,装饰器版本见 app.dependencies.require_permission
|
|
|
|
|
|
@router.get("/permissions/check")
|
|
|
|
|
|
async def check_my_permission(
|
|
|
|
|
|
resource: str = Query(..., description="资源"),
|
|
|
|
|
|
action: str = Query(..., description="操作"),
|
|
|
|
|
|
scope: str = Query("own", description="数据范围"),
|
|
|
|
|
|
admin: UserInfo = Depends(require_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""检查当前管理员是否拥有指定权限(给前端按钮级控制用)。
|
|
|
|
|
|
|
|
|
|
|
|
永远返回 true(因为 require_admin 已确保是 admin)。
|
|
|
|
|
|
此端点存在是为了给前端一个统一入口,实际权限由后端强制。
|
|
|
|
|
|
未来扩展:可加 current_user 参数(非 admin 角色也能调)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
resource: 资源
|
|
|
|
|
|
action: 操作
|
|
|
|
|
|
scope: 数据范围
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,包含 has_permission 字段
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.rbac_service import check_permission, ROLE_PERMISSIONS, permissions_to_strings
|
|
|
|
|
|
|
|
|
|
|
|
user_perms = {role: permissions_to_strings(perms) for role, perms in ROLE_PERMISSIONS.items()}
|
|
|
|
|
|
|
|
|
|
|
|
has_perm = check_permission(
|
|
|
|
|
|
user_roles=admin.roles,
|
|
|
|
|
|
user_permissions=user_perms,
|
|
|
|
|
|
required_resource=resource,
|
|
|
|
|
|
required_action=action,
|
|
|
|
|
|
required_scope=scope,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"has_permission": has_perm,
|
|
|
|
|
|
"resource": resource,
|
|
|
|
|
|
"action": action,
|
|
|
|
|
|
"scope": scope,
|
|
|
|
|
|
})
|