feat(auth): 添加超级管理员用户切换功能和相关日志记录

This commit is contained in:
Wenjie Zhang 2026-01-20 19:12:49 +08:00
parent f3da61b10a
commit 431f06f211
4 changed files with 292 additions and 157 deletions

View File

@ -1,5 +1,6 @@
import re
import uuid
from src.utils import logger
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File
from fastapi.security import OAuth2PasswordRequestForm
@ -9,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.storage.db.manager import db_manager
from src.storage.db.models import User, Department
from server.utils.auth_middleware import get_admin_user, get_current_user, get_db, get_required_user
from server.utils.auth_middleware import get_admin_user, get_superadmin_user, get_current_user, get_db, get_required_user
from server.utils.auth_utils import AuthUtils
from server.utils.user_utils import generate_unique_user_id, validate_username, is_valid_phone_number
from server.utils.common_utils import log_operation
@ -769,3 +770,58 @@ async def upload_user_avatar(
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"头像上传失败: {str(e)}")
# 路由:模拟用户登录(超级管理员专用)
@auth.post("/impersonate/{user_id}", response_model=Token)
async def impersonate_user(
user_id: int,
request: Request,
current_user: User = Depends(get_superadmin_user),
db: AsyncSession = Depends(get_db),
):
"""超级管理员模拟其他用户登录"""
# 查找目标用户
result = await db.execute(select(User).filter(User.id == user_id, User.is_deleted == 0))
target_user = result.scalar_one_or_none()
if target_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在",
)
# 不能模拟超级管理员
if target_user.role == "superadmin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="不能模拟超级管理员账户",
)
# 生成访问令牌
token_data = {"sub": str(target_user.id)}
access_token = AuthUtils.create_access_token(token_data)
# 获取部门名称
department_name = None
if target_user.department_id:
result = await db.execute(select(Department.name).filter(Department.id == target_user.department_id))
department_name = result.scalar_one_or_none()
# 记录操作(危险操作标记)
await log_operation(db, current_user.id, "⚠️ 危险操作-模拟用户", f"模拟用户: {target_user.username}", request)
# 控制台警告日志
logger.warning(f"⚠️ [危险操作] 超级管理员 {current_user.username} 模拟登录用户: {target_user.username}")
return {
"access_token": access_token,
"token_type": "bearer",
"user_id": target_user.id,
"username": target_user.username,
"user_id_login": target_user.user_id,
"phone_number": target_user.phone_number,
"avatar": target_user.avatar,
"role": target_user.role,
"department_id": target_user.department_id,
"department_name": department_name,
}

View File

@ -1,125 +1,151 @@
<template>
<div :class="['log-viewer', { fullscreen: state.isFullscreen }]" ref="logViewer">
<div class="control-panel">
<div class="button-group">
<a-button
@click="fetchLogs"
:loading="state.fetching"
:icon="h(ReloadOutlined)"
class="icon-only"
>
</a-button>
<a-button @click="clearLogs" :icon="h(ClearOutlined)" class="icon-only"> </a-button>
<a-button @click="printSystemConfig">
<template #icon><SettingOutlined /></template>
系统配置
</a-button>
<a-button @click="printUserInfo">
<template #icon><UserOutlined /></template>
用户信息
</a-button>
<a-button @click="printDatabaseInfo">
<template #icon><DatabaseOutlined /></template>
知识库信息
</a-button>
<a-button @click="printAgentConfig">
<template #icon><RobotOutlined /></template>
智能体配置
</a-button>
<a-button @click="toggleDebugMode" :type="infoStore.debugMode ? 'primary' : 'default'">
<template #icon><BugOutlined /></template>
Debug 模式: {{ infoStore.debugMode ? '开启' : '关闭' }}
</a-button>
<a-button @click="toggleFullscreen">
<template #icon>
<FullscreenOutlined v-if="!state.isFullscreen" />
<FullscreenExitOutlined v-else />
</template>
{{ state.isFullscreen ? '退出全屏' : '全屏' }}
</a-button>
<a-tooltip :title="state.autoRefresh ? '点击停止自动刷新' : '点击开启自动刷新'">
<a-modal
v-model:open="showModal"
title="调试面板(请在生产环境中谨慎使用)"
width="90%"
:footer="null"
:maskClosable="true"
:destroyOnClose="true"
class="debug-modal"
>
<div :class="['log-viewer', { fullscreen: state.isFullscreen }]" ref="logViewer">
<div class="control-panel">
<div class="button-group">
<a-button
:type="state.autoRefresh ? 'primary' : 'default'"
:class="{ 'auto-refresh-button': state.autoRefresh }"
@click="toggleAutoRefresh(!state.autoRefresh)"
@click="fetchLogs"
:loading="state.fetching"
:icon="h(ReloadOutlined)"
class="icon-only"
>
<template #icon>
<SyncOutlined :spin="state.autoRefresh" />
</template>
自动刷新
<span v-if="state.autoRefresh" class="refresh-interval">(5s)</span>
</a-button>
</a-tooltip>
</div>
<div class="filter-group">
<a-input-search
v-model:value="state.searchText"
placeholder="搜索日志..."
style="width: 200px; height: 32px"
@search="onSearch"
/>
<div class="log-level-selector">
<div class="multi-select-cards">
<div
v-for="level in logLevels"
:key="level.value"
class="option-card"
:class="{
selected: isLogLevelSelected(level.value),
unselected: !isLogLevelSelected(level.value)
}"
@click="toggleLogLevel(level.value)"
<a-button @click="clearLogs" :icon="h(ClearOutlined)" class="icon-only"> </a-button>
<a-button @click="printSystemConfig">
<template #icon><SettingOutlined /></template>
系统配置
</a-button>
<a-button @click="printUserInfo">
<template #icon><UserOutlined /></template>
用户信息
</a-button>
<a-button @click="printDatabaseInfo">
<template #icon><DatabaseOutlined /></template>
知识库信息
</a-button>
<a-button @click="printAgentConfig">
<template #icon><RobotOutlined /></template>
智能体配置
</a-button>
<a-button @click="toggleDebugMode" :type="infoStore.debugMode ? 'primary' : 'default'">
<template #icon><BugOutlined /></template>
Debug 模式: {{ infoStore.debugMode ? '开启' : '关闭' }}
</a-button>
<a-button @click="toggleFullscreen">
<template #icon>
<FullscreenOutlined v-if="!state.isFullscreen" />
<FullscreenExitOutlined v-else />
</template>
{{ state.isFullscreen ? '退出全屏' : '全屏' }}
</a-button>
<a-tooltip :title="state.autoRefresh ? '点击停止自动刷新' : '点击开启自动刷新'">
<a-button
:type="state.autoRefresh ? 'primary' : 'default'"
:class="{ 'auto-refresh-button': state.autoRefresh }"
@click="toggleAutoRefresh(!state.autoRefresh)"
>
<div class="option-content">
<span class="option-text">{{ level.label }}</span>
<div class="option-indicator">
<CheckCircleOutlined v-if="isLogLevelSelected(level.value)" />
<PlusCircleOutlined v-else />
<template #icon>
<SyncOutlined :spin="state.autoRefresh" />
</template>
自动刷新
<span v-if="state.autoRefresh" class="refresh-interval">(5s)</span>
</a-button>
</a-tooltip>
<a-button @click="openUserSwitcher">
<template #icon><SwapOutlined /></template>
切换用户
</a-button>
</div>
<div class="filter-group">
<a-input-search
v-model:value="state.searchText"
placeholder="搜索日志..."
style="width: 200px; height: 32px"
@search="onSearch"
/>
<div class="log-level-selector">
<div class="multi-select-cards">
<div
v-for="level in logLevels"
:key="level.value"
class="option-card"
:class="{
selected: isLogLevelSelected(level.value),
unselected: !isLogLevelSelected(level.value)
}"
@click="toggleLogLevel(level.value)"
>
<div class="option-content">
<span class="option-text">{{ level.label }}</span>
<div class="option-indicator">
<CheckCircleOutlined v-if="isLogLevelSelected(level.value)" />
<PlusCircleOutlined v-else />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div ref="logContainer" class="log-container">
<div v-if="processedLogs.length" class="log-lines">
<div
v-for="(log, index) in processedLogs"
:key="index"
:class="['log-line', `level-${log.level.toLowerCase()}`]"
>
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="level">{{ log.level }}</span>
<span class="module">{{ log.module }}</span>
<span class="message">{{ log.message }}</span>
<div ref="logContainer" class="log-container">
<div v-if="processedLogs.length" class="log-lines">
<div
v-for="(log, index) in processedLogs"
:key="index"
:class="['log-line', `level-${log.level.toLowerCase()}`]"
>
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="level">{{ log.level }}</span>
<span class="module">{{ log.module }}</span>
<span class="message">{{ log.message }}</span>
</div>
</div>
<div v-else class="empty-logs">暂无日志</div>
</div>
<div v-else class="empty-logs">暂无日志</div>
<p v-if="error" class="error">{{ error }}</p>
<!-- 用户切换 Modal -->
<a-modal
v-model:open="state.showUserSwitcher"
title="切换用户"
:confirmLoading="state.switchingUser"
:footer="null"
:bodyStyle="{ padding: '12px' }"
>
<a-list item-layout="horizontal" :data-source="state.users">
<template #renderItem="{ item }">
<a-list-item @click="switchToUser(item)" style="cursor: pointer">
<a-list-item-meta :title="item.username" :description="item.role" />
</a-list-item>
</template>
<template #empty>
<a-empty description="暂无用户" />
</template>
</a-list>
</a-modal>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</a-modal>
</template>
<script setup>
import {
ref,
onMounted,
onActivated,
onUnmounted,
nextTick,
reactive,
computed,
h,
toRaw
} from 'vue'
import { ref, reactive, computed, defineModel, onMounted, onActivated, onUnmounted, nextTick, toRaw, h } from 'vue'
const showModal = defineModel('show')
import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user'
import { useDatabaseStore } from '@/stores/database'
import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { useThrottleFn } from '@vueuse/core'
import { message } from 'ant-design-vue'
import { message, Modal, List as AList, ListItem as AListItem, ListItemMeta as AListItemMeta, Empty as AEmpty } from 'ant-design-vue'
import {
FullscreenOutlined,
FullscreenExitOutlined,
@ -132,11 +158,12 @@ import {
UserOutlined,
DatabaseOutlined,
RobotOutlined,
BugOutlined
BugOutlined,
SwapOutlined
} from '@ant-design/icons-vue'
import dayjs from '@/utils/time'
import { configApi } from '@/apis/system_api'
import { checkAdminPermission } from '@/stores/user'
import { checkSuperAdminPermission } from '@/stores/user'
const configStore = useConfigStore()
const userStore = useUserStore()
@ -162,7 +189,10 @@ const state = reactive({
searchText: '',
selectedLevels: logLevels.map((l) => l.value),
rawLogs: [],
isFullscreen: false
isFullscreen: false,
showUserSwitcher: false,
users: [],
switchingUser: false
})
const error = ref('')
@ -219,7 +249,7 @@ const processedLogs = computed(() => {
//
const fetchLogs = async () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
state.fetching = true
try {
@ -245,7 +275,7 @@ const fetchLogs = async () => {
//
const clearLogs = () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
state.rawLogs = []
}
@ -280,7 +310,7 @@ const toggleLogLevel = (level) => {
//
const toggleAutoRefresh = (value) => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
if (value) {
autoRefreshInterval = setInterval(fetchLogs, 5000)
@ -296,7 +326,7 @@ const toggleAutoRefresh = (value) => {
//
const toggleFullscreen = async () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
try {
if (!state.isFullscreen) {
@ -329,7 +359,7 @@ const handleFullscreenChange = () => {
}
onMounted(() => {
if (checkAdminPermission()) {
if (checkSuperAdminPermission()) {
fetchLogs()
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
@ -338,7 +368,7 @@ onMounted(() => {
})
onActivated(() => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
if (state.autoRefresh) {
toggleAutoRefresh(true)
@ -359,14 +389,14 @@ onUnmounted(() => {
//
const printSystemConfig = () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
console.log('=== 系统配置 ===')
console.log(config)
}
//
const printUserInfo = () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
console.log('=== 用户信息 ===')
const userInfo = {
token: userStore.token ? '*** (已隐藏)' : null,
@ -385,7 +415,7 @@ const printUserInfo = () => {
//
const printDatabaseInfo = async () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
try {
console.log('=== 知识库信息 ===')
@ -418,13 +448,13 @@ const printDatabaseInfo = async () => {
// Debug
const toggleDebugMode = () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
infoStore.toggleDebugMode()
}
//
const printAgentConfig = async () => {
if (!checkAdminPermission()) return
if (!checkSuperAdminPermission()) return
try {
console.log('=== 智能体配置信息 ===')
@ -486,6 +516,66 @@ const printAgentConfig = async () => {
message.error('获取智能体配置失败: ' + error.message)
}
}
//
const fetchUsers = async () => {
try {
const response = await fetch('/api/auth/users', {
headers: userStore.getAuthHeaders()
})
if (!response.ok) {
throw new Error('获取用户列表失败')
}
state.users = await response.json()
} catch (err) {
message.error(`获取用户列表失败: ${err.message}`)
}
}
//
const openUserSwitcher = () => {
if (!checkSuperAdminPermission()) return
state.showUserSwitcher = true
fetchUsers()
}
//
const switchToUser = async (user) => {
if (!checkSuperAdminPermission()) return
//
Modal.confirm({
title: '⚠️ 危险操作确认',
content: `确定要切换为用户 "${user.username}" 吗?此操作将被记录。`,
okText: '确认切换',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
state.switchingUser = true
try {
const response = await fetch(`/api/auth/impersonate/${user.id}`, {
method: 'POST',
headers: userStore.getAuthHeaders()
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '切换用户失败')
}
const data = await response.json()
// token
localStorage.setItem('user_token', data.access_token)
message.success(`已切换用户: ${user.username}`)
state.showUserSwitcher = false
//
window.location.reload()
} catch (err) {
message.error(`切换失败: ${err.message}`)
} finally {
state.switchingUser = false
}
}
})
}
</script>
<style scoped>

View File

@ -26,28 +26,36 @@
</div>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="docs" @click="openDocs" :icon="h(BookOpen, { size: '16' })">
<a-menu-item key="docs" @click="openDocs" :icon="BookOpenIcon">
<span class="menu-text">文档中心</span>
</a-menu-item>
<a-menu-item
key="theme"
@click="toggleTheme"
:icon="h(themeStore.isDark ? Sun : Moon, { size: '16' })"
:icon="themeStore.isDark ? SunIcon : MoonIcon"
>
<span class="menu-text">{{
themeStore.isDark ? '切换到浅色模式' : '切换到深色模式 (Beta)'
}}</span>
</a-menu-item>
<a-menu-divider v-if="userStore.isAdmin" />
<a-menu-item
v-if="userStore.isSuperAdmin"
key="debug"
@click="showDebug = true"
:icon="TerminalIcon"
>
<span class="menu-text">调试面板非生产环境</span>
</a-menu-item>
<a-menu-item
v-if="userStore.isAdmin"
key="setting"
@click="goToSetting"
:icon="h(Settings, { size: '16' })"
:icon="SettingsIcon"
>
<span class="menu-text">系统设置</span>
</a-menu-item>
<a-menu-item key="logout" @click="logout" :icon="h(LogOut, { size: '16' })">
<a-menu-item key="logout" @click="logout" :icon="LogOutIcon">
<span class="menu-text">退出登录</span>
</a-menu-item>
</a-menu>
@ -165,6 +173,9 @@
</div>
</div>
</a-modal>
<!-- 调试面板 Modal -->
<DebugComponent v-model:show="showDebug" />
</div>
</template>
@ -172,10 +183,7 @@
import { computed, ref, inject, h } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
//
//
//
//
import DebugComponent from '@/components/DebugComponent.vue'
import { message } from 'ant-design-vue'
import {
CircleUser,
@ -186,7 +194,8 @@ import {
User,
LogOut,
Upload,
Settings
Settings,
Terminal
} from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
@ -194,6 +203,17 @@ const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
// Vue
const BookOpenIcon = h(BookOpen, { size: '16' })
const SunIcon = h(Sun, { size: '16' })
const MoonIcon = h(Moon, { size: '16' })
const TerminalIcon = h(Terminal, { size: '16' })
const SettingsIcon = h(Settings, { size: '16' })
const LogOutIcon = h(LogOut, { size: '16' })
//
const showDebug = ref(false)
// Inject settings modal methods
const { openSettingsModal } = inject('settingsModal', {})

View File

@ -1,9 +1,8 @@
<script setup>
import { ref, reactive, onMounted, useTemplateRef, computed, provide } from 'vue'
import { ref, reactive, onMounted, computed, provide } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { GithubOutlined } from '@ant-design/icons-vue'
import { Bot, Waypoints, LibraryBig, BarChart3, CircleCheck } from 'lucide-vue-next'
import { onLongPress } from '@vueuse/core'
import { useConfigStore } from '@/stores/config'
import { useDatabaseStore } from '@/stores/database'
@ -32,7 +31,6 @@ const isLoadingStars = ref(false)
// Add state for debug modal
const showDebugModal = ref(false)
const htmlRefHook = useTemplateRef('htmlRefHook')
// Add state for settings modal
const showSettingsModal = ref(false)
@ -42,21 +40,6 @@ const openSettingsModal = () => {
showSettingsModal.value = true
}
// Setup long press for debug modal
onLongPress(
htmlRefHook,
() => {
console.log('long press')
showDebugModal.value = true
},
{
delay: 1000, // 1
modifiers: {
prevent: true
}
}
)
// Handle debug modal close
const handleDebugModalClose = () => {
showDebugModal.value = false
@ -181,7 +164,7 @@ provide('settingsModal', {
</a-tooltip>
</div>
</div>
<div ref="htmlRefHook" class="fill debug-trigger"></div>
<div class="fill"></div>
<div class="github nav-item">
<a-tooltip placement="right">
<template #title>欢迎 Star</template>
@ -233,15 +216,6 @@ provide('settingsModal', {
width: 100%;
height: 100vh;
min-width: var(--min-width);
.debug-panel {
position: absolute;
z-index: 100;
right: 0;
bottom: 50px;
border-radius: 20px 0 0 20px;
cursor: pointer;
}
}
div.header,
@ -277,12 +251,7 @@ div.header,
gap: 16px;
}
// debug
.debug-trigger {
position: relative;
height: 100%;
width: 100%;
min-height: 20px;
.fill {
flex-grow: 1;
}