feat(backend): RBAC admin roles + runtime logs service
This commit is contained in:
@@ -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 NULL,nulls_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(
|
||||
|
||||
Reference in New Issue
Block a user