feat(backend): RBAC admin roles + runtime logs service

This commit is contained in:
Simon
2026-07-09 13:46:15 +08:00
parent 7ffc6c8e23
commit d480aa4c1d
12 changed files with 726 additions and 25 deletions
+79 -8
View File
@@ -13,12 +13,14 @@ import logging
from datetime import datetime
from typing import List, Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, UserInfo, require_role
from app.dependencies import get_current_user, UserInfo, require_role, dep_redis
from app.database import get_db
from app.models.employee import Employee
from app.models.role import Role
from app.models.role_mapping_rule import RoleMappingRule
from app.models.user_role import UserRole
@@ -30,6 +32,7 @@ from app.schemas.role import (
RoleResponse,
UserRoleResponse,
)
from app.services.employee_directory import get_org_directory, resolve_target
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
@@ -146,22 +149,27 @@ async def list_user_role_assignments(
"""
# 使用 LEFT OUTER JOIN:即使 user_roles.role_id 在 roles 表中已不存在
# (例如角色被重建导致 UUID 变化),也保留该条分配记录,避免已分配用户被静默隐藏。
# 同时 LEFT JOIN employees 表获取员工姓名。
# assigned_at 定义为 NOT NULLnulls_last() 无意义,直接降序即可(SQLite/PG 通用)。
stmt = (
select(UserRole, Role)
select(UserRole, Role, Employee)
.outerjoin(Role, UserRole.role_id == Role.id)
.outerjoin(Employee, UserRole.employee_id == Employee.employee_id)
.order_by(UserRole.assigned_at.desc())
)
result = await db.execute(stmt)
rows = result.all()
assignments = []
for user_role, role in rows:
for user_role, role, employee in rows:
# role 可能为 None(孤儿记录),做兜底展示,而不是丢弃该用户
role_name = role.name if role else "unknown"
role_display = (role.display_name or role.name) if role else "未知角色"
# employee 可能为 None(员工已从组织架构移除)
employee_name = employee.name if employee else ""
assignments.append({
"employee_id": user_role.employee_id,
"employee_name": employee_name,
"role_name": role_name,
"role_display_name": role_display,
"source": user_role.source or "manual",
@@ -183,22 +191,46 @@ async def assign_role(
body: RoleAssignRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(dep_redis),
):
"""手动分配角色。
为指定用户分配角色,记录分配者和分配原因。
支持输入「员工账号 或 姓名」:
- 先经 employee_directory.resolve_target 解析为企微 UserID
- 再用企微通讯录实时校验该员工确实属于企微组织架构(不在组织内则拒绝)。
安全限制:禁止管理员给自己分配角色。
Args:
body: 分配角色请求
body: 分配角色请求target=账号/姓名,或兼容 employee_id
admin: 管理员(权限校验)
db: 数据库会话
redis: Redis 客户端(组织目录缓存用)
Returns:
Dict: 统一响应格式
"""
# 取待解析的目标(优先 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"]
# 安全限制:禁止管理员给自己分配角色
if body.employee_id == admin.employee_id:
if admin.employee_id and employee_id == admin.employee_id:
raise AppException(4014, "不能给自己分配角色")
# 查询目标角色
@@ -211,7 +243,7 @@ async def assign_role(
# 检查是否已拥有该角色
existing_stmt = select(UserRole).where(
UserRole.employee_id == body.employee_id,
UserRole.employee_id == employee_id,
UserRole.role_id == role.id,
)
existing_result = await db.execute(existing_stmt)
@@ -222,7 +254,7 @@ async def assign_role(
# 创建用户角色关联
user_role = UserRole(
employee_id=body.employee_id,
employee_id=employee_id,
role_id=role.id,
source="manual",
assigned_by=admin.employee_id,
@@ -230,11 +262,50 @@ async def assign_role(
db.add(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}")
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
return success_response(message=f"角色 {body.role_name} 分配成功")
# ---------- 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})
# ---------- POST /api/admin/roles/revoke ----------
@router.post("/revoke")
async def revoke_role(