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
+29 -3
View File
@@ -15,6 +15,7 @@
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
from uuid import UUID
@@ -895,12 +896,37 @@ async def get_agent_performance(
async def get_system_logs(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(50, ge=1, le=200, description="每页条数"),
config_key: Optional[str] = Query(None, description="按配置键精确筛选"),
changed_by: Optional[str] = Query(None, description="按操作人 agent_id 精确筛选"),
from_time: Optional[datetime] = Query(None, alias="from", description="变更时间起始(ISO8601, 闭区间)"),
to_time: Optional[datetime] = Query(None, alias="to", description="变更时间截止(ISO8601, 闭区间)"),
admin: Agent = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取系统日志(配置变更日志)"""
result = await admin_service.get_system_logs(db, page=page, page_size=page_size)
return success_response(data=result)# ---------- GET /api/admin/integrations/lianruan/terminals/{devname}/detail ----------
"""获取系统日志(配置变更日志),支持配置键/操作人/时间范围筛选。
Args:
config_key: 按配置键精确匹配(可选)
changed_by: 按操作人 agent_id 精确匹配(可选)
from_time: 变更时间起始(别名 from,可选)
to_time: 变更时间截止(别名 to,可选)
Returns:
Dict: 统一响应格式,包含筛选后的配置变更日志
"""
result = await admin_service.get_system_logs(
db,
page=page,
page_size=page_size,
config_key=config_key,
changed_by=changed_by,
from_time=from_time,
to_time=to_time,
)
return success_response(data=result)
# ---------- GET /api/admin/integrations/lianruan/terminals/{devname}/detail ----------
@router.get("/integrations/lianruan/terminals/{devname}/detail")
async def get_lianruan_terminal_detail(
devname: str,
+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(
+1
View File
@@ -243,6 +243,7 @@ async def send_message(
try:
# 构建消息载荷
msg_payload = MessageResponse.model_validate(message).model_dump()
msg_payload["message_id"] = msg_payload["id"]
# 构建 WebSocket 事件
ws_event = {
+6
View File
@@ -243,6 +243,12 @@ api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"])
from app.api.audit_logs import router as audit_logs_router
api_router.include_router(audit_logs_router, tags=["审计日志"])
# 运行期日志 API (standard SOP 第三阶段 — 管理后台日志体系)
# GET /api/admin/runtime-logs — 分页 + 多条件筛选(级别/时间/关键字)
# GET /api/admin/runtime-logs?download=true — 命中行文本下载(附件)
from app.api.runtime_logs import router as runtime_logs_router
api_router.include_router(runtime_logs_router, tags=["运行期日志"])
# 阶段5 自动化闭环 API
# POST /itportal/automation/sessions — 创建自动化会话
# GET /itportal/automation/sessions — 会话列表
+71
View File
@@ -0,0 +1,71 @@
# =============================================================================
# 企微IT智能服务台 — 运行期日志 API (standard SOP 第三阶段)
# =============================================================================
# 说明:管理后台查看后端运行期日志
# GET /admin/runtime-logs 正常查询(分页 + 级别/时间/关键字筛选)
# GET /admin/runtime-logs?download=true 下载命中行(text/plain 附件)
# 权限:require_admin(非 admin 返回 403,由依赖装饰器强制)
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from app.dependencies import require_admin, get_current_user, UserInfo
from app.services.runtime_log_service import (
iter_runtime_log_lines,
query_runtime_logs,
)
from app.utils.response import success_response
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/runtime-logs", tags=["运行期日志"])
@router.get("")
@require_admin
async def get_runtime_logs(
level: str = Query("INFO", description="日志级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别"),
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
to_time: Optional[datetime] = Query(None, alias="to", description="结束时间(ISO8601)"),
keyword: Optional[str] = Query(None, description="按消息关键字子串筛选(大小写不敏感)"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
download: bool = Query(False, description="true 时返回命中行文本下载"),
current_user: UserInfo = Depends(get_current_user),
):
"""查询后端运行期日志(分页 + 级别/时间/关键字筛选)。
权限:仅 admin 角色可访问,非 admin 由 require_admin 装饰器返回 403。
"""
# 下载模式:流式返回命中行文本,触发浏览器文件下载
if download:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
headers = {
"Content-Disposition": f'attachment; filename="runtime-logs-{ts}.log"'
}
return StreamingResponse(
iter_runtime_log_lines(
level=level,
from_time=from_time,
to_time=to_time,
keyword=keyword,
),
media_type="text/plain; charset=utf-8",
headers=headers,
)
# 普通查询:分页返回结构化条目
result = await query_runtime_logs(
level=level,
from_time=from_time,
to_time=to_time,
keyword=keyword,
page=page,
page_size=page_size,
)
return success_response(data=result)