2026-06-14 16:49:18 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微IT智能服务台 — 统一认证依赖
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 说明:提供统一的认证依赖函数,支持:
|
|
|
|
|
|
# 1. get_current_user: 获取当前用户信息(包含角色)
|
|
|
|
|
|
# 2. require_role: 角色验证装饰器
|
|
|
|
|
|
# 3. require_admin: 管理员权限验证
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
2026-06-16 19:24:27 +08:00
|
|
|
|
import inspect
|
2026-06-14 16:49:18 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from functools import wraps
|
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
import redis.asyncio as aioredis
|
|
|
|
|
|
from fastapi import Depends, HTTPException, status
|
|
|
|
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
|
|
|
|
|
from app.services.token_service import TokenService
|
2026-06-21 03:08:54 +08:00
|
|
|
|
from app.utils.response import AppException
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# HTTP Bearer 认证方案
|
|
|
|
|
|
security = HTTPBearer()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class UserInfo:
|
|
|
|
|
|
"""用户信息数据类。
|
|
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
|
employee_id: 企微 UserID
|
|
|
|
|
|
name: 用户姓名
|
|
|
|
|
|
department: 部门
|
|
|
|
|
|
avatar: 头像URL
|
|
|
|
|
|
roles: 角色列表
|
|
|
|
|
|
current_role: 当前选择的角色
|
|
|
|
|
|
login_source: 登录来源
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
employee_id: str
|
|
|
|
|
|
name: str
|
|
|
|
|
|
department: str
|
|
|
|
|
|
avatar: str
|
|
|
|
|
|
roles: List[str]
|
|
|
|
|
|
current_role: str
|
|
|
|
|
|
login_source: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Redis 连接池(单例)
|
|
|
|
|
|
_redis_pool: Optional[aioredis.Redis] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_redis() -> aioredis.Redis:
|
|
|
|
|
|
"""获取 Redis 连接。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
aioredis.Redis: Redis 异步客户端
|
|
|
|
|
|
"""
|
|
|
|
|
|
global _redis_pool
|
|
|
|
|
|
if _redis_pool is None:
|
|
|
|
|
|
_redis_pool = settings.create_redis_client()
|
|
|
|
|
|
return _redis_pool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 共享服务实例(用于 wecom_callback.py 等模块)
|
|
|
|
|
|
# 这些函数提供同步获取服务实例的方式,用于非 FastAPI DI 的场景
|
|
|
|
|
|
def get_shared_redis() -> aioredis.Redis:
|
|
|
|
|
|
"""获取 Redis 客户端(同步版本,用于非 async 场景)。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
aioredis.Redis: Redis 客户端实例
|
|
|
|
|
|
"""
|
|
|
|
|
|
return settings.create_redis_client()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_shared_wecom_service():
|
|
|
|
|
|
"""获取 WecomService 共享实例。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
WecomService: 企微服务实例
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.wecom_service import WecomService
|
|
|
|
|
|
return WecomService(settings.create_redis_client())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_shared_ai_handler():
|
|
|
|
|
|
"""获取 AIHandler 共享实例。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
AIHandler: AI 处理器实例
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.ai_handler import AIHandler
|
|
|
|
|
|
from app.services.ai_service import AIService
|
|
|
|
|
|
return AIHandler(ai_service=AIService())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# FastAPI Depends 函数(用于路由依赖注入)
|
|
|
|
|
|
async def dep_redis() -> aioredis.Redis:
|
|
|
|
|
|
"""Redis 客户端依赖注入。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
aioredis.Redis: Redis 异步客户端
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await get_redis()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dep_wecom_service():
|
|
|
|
|
|
"""WecomService 依赖注入。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
WecomService: 企微服务实例
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.wecom_service import WecomService
|
|
|
|
|
|
return WecomService(settings.create_redis_client())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dep_ai_handler():
|
|
|
|
|
|
"""AIHandler 依赖注入。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
AIHandler: AI 处理器实例
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.ai_handler import AIHandler
|
|
|
|
|
|
from app.services.ai_service import AIService
|
|
|
|
|
|
return AIHandler(ai_service=AIService())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dep_wingman_service():
|
|
|
|
|
|
"""WingmanService 依赖注入。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
WingmanService: AI Wingman 服务实例
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.wingman_service import WingmanService
|
|
|
|
|
|
return WingmanService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 应用生命周期管理函数
|
|
|
|
|
|
async def init_shared_services():
|
|
|
|
|
|
"""初始化共享服务(应用启动时调用)。
|
|
|
|
|
|
|
|
|
|
|
|
创建 Redis 连接池,初始化共享服务实例。
|
|
|
|
|
|
"""
|
|
|
|
|
|
global _redis_pool
|
|
|
|
|
|
_redis_pool = settings.create_redis_client()
|
|
|
|
|
|
logger.info("共享服务初始化完成")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def cleanup_shared_services():
|
|
|
|
|
|
"""清理共享服务(应用关闭时调用)。
|
|
|
|
|
|
|
|
|
|
|
|
关闭 Redis 连接池。
|
|
|
|
|
|
"""
|
|
|
|
|
|
global _redis_pool
|
|
|
|
|
|
if _redis_pool:
|
|
|
|
|
|
await _redis_pool.close()
|
|
|
|
|
|
_redis_pool = None
|
|
|
|
|
|
logger.info("共享服务清理完成")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_user(
|
|
|
|
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
|
|
|
|
) -> UserInfo:
|
|
|
|
|
|
"""统一认证依赖:从 Token 获取用户信息。
|
|
|
|
|
|
|
|
|
|
|
|
支持新旧两种 Token 格式。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
credentials: HTTP Bearer Token
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
UserInfo: 用户信息
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
HTTPException: Token 无效或已过期
|
|
|
|
|
|
"""
|
|
|
|
|
|
token = credentials.credentials
|
|
|
|
|
|
|
|
|
|
|
|
# 获取 Redis 连接
|
|
|
|
|
|
redis_client = await get_redis()
|
|
|
|
|
|
|
|
|
|
|
|
# 创建 Token 服务
|
|
|
|
|
|
token_service = TokenService(redis_client)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取用户信息
|
|
|
|
|
|
user_info = await token_service.get_user_info(token)
|
|
|
|
|
|
|
|
|
|
|
|
if not user_info:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
|
detail="Token 无效或已过期",
|
|
|
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return UserInfo(
|
|
|
|
|
|
employee_id=user_info["employee_id"],
|
|
|
|
|
|
name=user_info.get("name", ""),
|
|
|
|
|
|
department=user_info.get("department", ""),
|
|
|
|
|
|
avatar=user_info.get("avatar", ""),
|
|
|
|
|
|
roles=user_info.get("roles", ["user"]),
|
|
|
|
|
|
current_role=user_info.get("current_role", "user"),
|
|
|
|
|
|
login_source=user_info.get("login_source", "portal"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_role(*required_roles: str):
|
|
|
|
|
|
"""角色验证装饰器。
|
|
|
|
|
|
|
|
|
|
|
|
检查用户是否拥有指定角色之一。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
*required_roles: 允许的角色列表
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
装饰器函数
|
|
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
@router.get("/api/admin/dashboard")
|
|
|
|
|
|
@require_role("admin")
|
|
|
|
|
|
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
|
|
|
|
|
|
pass
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def decorator(func):
|
2026-06-16 19:24:27 +08:00
|
|
|
|
# 合并 func 签名 + current_user 参数,让 FastAPI 能正确解析 Depends
|
|
|
|
|
|
# (v0.5.6 修复:之前用 @wraps,FastAPI 看到的是 __wrapped__ 的签名,
|
|
|
|
|
|
# 没有 current_user,导致 Depends 默认值未被解析,current_user 实际是 Depends 对象)
|
|
|
|
|
|
sig = inspect.signature(func)
|
|
|
|
|
|
params = list(sig.parameters.values())
|
|
|
|
|
|
params.append(
|
|
|
|
|
|
inspect.Parameter(
|
|
|
|
|
|
'current_user',
|
|
|
|
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
|
|
|
|
annotation=UserInfo,
|
|
|
|
|
|
default=Depends(get_current_user),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
new_sig = sig.replace(parameters=params)
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
@wraps(func)
|
2026-06-16 19:24:27 +08:00
|
|
|
|
async def wrapper(*args, **kwargs):
|
|
|
|
|
|
# FastAPI 已经把 current_user 注入了 kwargs
|
|
|
|
|
|
current_user = kwargs.pop('current_user')
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# 检查用户是否有任一所需角色
|
|
|
|
|
|
user_roles = set(current_user.roles)
|
|
|
|
|
|
required = set(required_roles)
|
|
|
|
|
|
|
|
|
|
|
|
if not user_roles.intersection(required):
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"用户 {current_user.employee_id} 角色不足: "
|
|
|
|
|
|
f"拥有 {current_user.roles}, 需要 {required_roles}"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
|
detail=f"需要以下角色之一: {', '.join(required_roles)}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return await func(*args, current_user=current_user, **kwargs)
|
|
|
|
|
|
|
2026-06-16 19:24:27 +08:00
|
|
|
|
# 关键:让 FastAPI 用合并后的签名,这样它能看到 current_user 这个 Depends 参数
|
|
|
|
|
|
wrapper.__signature__ = new_sig
|
2026-06-14 16:49:18 +08:00
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
return decorator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_admin(func):
|
|
|
|
|
|
"""管理员权限验证装饰器。
|
|
|
|
|
|
|
|
|
|
|
|
等同于 @require_role("admin")。
|
|
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
@router.get("/api/admin/dashboard")
|
|
|
|
|
|
@require_admin
|
|
|
|
|
|
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
|
|
|
|
|
|
pass
|
|
|
|
|
|
"""
|
|
|
|
|
|
return require_role("admin")(func)
|
2026-06-21 03:08:54 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 高危操作 OTP 守卫依赖(Phase 1.3 task #19)
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 决策来源:otm-secondary-auth.md
|
|
|
|
|
|
# 触发场景:管理员执行 5 类高危操作前,必须在 30 分钟内通过 OTP 二次验证
|
|
|
|
|
|
# 验证流程:
|
|
|
|
|
|
# 1. 管理员先调 /api/mfa/verify 校验 TOTP 验证码(蜂鸟 SMS 备用)
|
|
|
|
|
|
# 2. 验证通过后 mfa.py 在 Redis 写 mfa:verified:{employee_id},TTL=1800 秒
|
|
|
|
|
|
# 3. 高危操作端点 Depends(require_high_risk_otp) 时:
|
|
|
|
|
|
# - 检查角色:admin(403 否则)
|
|
|
|
|
|
# - 检查 Redis key:mfa:verified:{employee_id}(不存在则 raise 2001)
|
|
|
|
|
|
# 4. 前端收到 2001 → 弹 OTP 输入框 → 重试
|
|
|
|
|
|
#
|
|
|
|
|
|
# 5 类高危操作清单(与 otm-secondary-auth.md 对齐):
|
|
|
|
|
|
# 1. role_change 改权限 POST /api/admin/roles/assign
|
|
|
|
|
|
# 2. config_change 改配置 PUT /api/admin/configs/{key}
|
|
|
|
|
|
# 3. data_export 导出数据 GET /api/admin/export/*
|
|
|
|
|
|
# 4. account_disable 封号 DELETE /api/admin/agents/{id}
|
|
|
|
|
|
# 5. account_create_reset 新增账号/重置 POST /api/admin/agents, /api/admin/mfa/reset/{id}
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
# 高危操作白名单(category → 元数据)
|
|
|
|
|
|
# 用于演示路由 + 文档化,前端可读此表知道哪些操作需要 OTP
|
|
|
|
|
|
HIGH_RISK_OPERATIONS = {
|
|
|
|
|
|
"role_change": {
|
|
|
|
|
|
"category": "改权限",
|
|
|
|
|
|
"require_otp": True,
|
|
|
|
|
|
"examples": ["POST /api/admin/roles/assign", "POST /api/admin/roles/revoke"],
|
|
|
|
|
|
"description": "分配或撤销用户角色",
|
|
|
|
|
|
},
|
|
|
|
|
|
"config_change": {
|
|
|
|
|
|
"category": "改配置",
|
|
|
|
|
|
"require_otp": True,
|
|
|
|
|
|
"examples": ["PUT /api/admin/configs/{key}"],
|
|
|
|
|
|
"description": "修改系统配置项",
|
|
|
|
|
|
},
|
|
|
|
|
|
"data_export": {
|
|
|
|
|
|
"category": "导出数据",
|
|
|
|
|
|
"require_otp": True,
|
|
|
|
|
|
"examples": ["GET /api/admin/export/*"],
|
|
|
|
|
|
"description": "导出敏感数据(会话、坐席统计等)",
|
|
|
|
|
|
},
|
|
|
|
|
|
"account_disable": {
|
|
|
|
|
|
"category": "封号",
|
|
|
|
|
|
"require_otp": True,
|
|
|
|
|
|
"examples": ["DELETE /api/admin/agents/{id}"],
|
|
|
|
|
|
"description": "禁用/删除坐席账号",
|
|
|
|
|
|
},
|
|
|
|
|
|
"account_create_reset": {
|
|
|
|
|
|
"category": "新增账号/重置",
|
|
|
|
|
|
"require_otp": True,
|
|
|
|
|
|
"examples": ["POST /api/admin/agents", "POST /api/admin/mfa/reset/{id}"],
|
|
|
|
|
|
"description": "新增坐席或重置 MFA",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# MFA 验证通过的 Redis key 前缀
|
|
|
|
|
|
# 由 mfa.py 在 /api/mfa/verify 成功后写入,TTL=1800 秒
|
|
|
|
|
|
MFA_VERIFIED_KEY_PREFIX = "mfa:verified:"
|
|
|
|
|
|
|
|
|
|
|
|
# MFA 验证有效期(30 分钟,与 otm-secondary-auth.md 决策一致)
|
|
|
|
|
|
MFA_VERIFIED_TTL_SECONDS = 30 * 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def require_high_risk_otp(
|
|
|
|
|
|
current_user: UserInfo = Depends(get_current_user),
|
|
|
|
|
|
) -> UserInfo:
|
|
|
|
|
|
"""高危操作 OTP 守卫(管理员触发高危操作前必过)。
|
|
|
|
|
|
|
|
|
|
|
|
业务规则(来自 otm-secondary-auth.md 2026-06-21 决策):
|
|
|
|
|
|
1. 仅 admin 角色需要过 OTP(agent/user 直接 403)
|
|
|
|
|
|
2. 必须在 30 分钟内通过 /api/mfa/verify 校验过 OTP
|
|
|
|
|
|
3. 验证失败的 key 不算(空字符串/已过期)
|
|
|
|
|
|
|
|
|
|
|
|
鉴权流程:
|
|
|
|
|
|
- 请求携带 Bearer Token → get_current_user 解析 UserInfo
|
|
|
|
|
|
- 检查 UserInfo.roles 是否含 "admin"(否则 4003 仅管理员)
|
|
|
|
|
|
- 检查 Redis mfa:verified:{employee_id} 是否存在(否则 2001 需 OTP)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
current_user: 当前用户(FastAPI 自动注入)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
UserInfo: 当前用户(已通过 OTP 守卫)
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
AppException(4003, "仅管理员可执行此操作"): 非管理员角色
|
|
|
|
|
|
AppException(2001, "高危操作需要 OTP 二次验证"): admin 但未在 30 分钟内过 OTP
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 第 1 关:角色检查 - 只有 admin 才需要 OTP 验证
|
|
|
|
|
|
# 注: current_role 是当前激活角色,roles 是全部角色,两者都查(双保险)
|
|
|
|
|
|
user_roles = current_user.roles or []
|
|
|
|
|
|
is_admin = (
|
|
|
|
|
|
current_user.current_role == "admin"
|
|
|
|
|
|
or "admin" in user_roles
|
|
|
|
|
|
)
|
|
|
|
|
|
if not is_admin:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"用户 {current_user.employee_id} 尝试高危操作但不是 admin: "
|
|
|
|
|
|
f"current_role={current_user.current_role}, roles={user_roles}"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise AppException(
|
|
|
|
|
|
code=4003,
|
|
|
|
|
|
message="仅管理员可执行此高危操作",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 第 2 关:OTP 验证标记检查 - Redis mfa:verified:{employee_id}
|
|
|
|
|
|
redis_client = await get_redis()
|
|
|
|
|
|
verified_key = f"{MFA_VERIFIED_KEY_PREFIX}{current_user.employee_id}"
|
|
|
|
|
|
verified = await redis_client.get(verified_key)
|
|
|
|
|
|
|
|
|
|
|
|
# 注:空字符串/null/bytes 都算"未通过"
|
|
|
|
|
|
if not verified:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"管理员 {current_user.employee_id} 未通过 OTP 守卫: "
|
|
|
|
|
|
f"Redis key '{verified_key}' 不存在或已过期"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise AppException(
|
|
|
|
|
|
code=2001,
|
|
|
|
|
|
message="高危操作需要 OTP 二次验证,请先完成 OTP 验证",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 防御性:刷新 TTL(滑动窗口)—— 如果管理员持续在做高危操作,
|
|
|
|
|
|
# 不用反复输 OTP。但要求单次操作 < 30 分钟间隔。
|
|
|
|
|
|
# 注: mfa.py 写入时已设 1800 秒 TTL,这里只在存在时刷新
|
|
|
|
|
|
if hasattr(redis_client, "expire"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
await redis_client.expire(verified_key, MFA_VERIFIED_TTL_SECONDS)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# 刷新失败不影响主流程,仅记录
|
|
|
|
|
|
logger.debug(f"刷新 OTP verified TTL 失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"管理员 {current_user.employee_id} 通过 OTP 守卫,执行高危操作"
|
|
|
|
|
|
)
|
|
|
|
|
|
return current_user
|