feat(backend): RBAC admin roles + runtime logs service
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 — 会话列表
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user