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 re
import uuid import uuid
from src.utils import logger
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File
from fastapi.security import OAuth2PasswordRequestForm 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.manager import db_manager
from src.storage.db.models import User, Department 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.auth_utils import AuthUtils
from server.utils.user_utils import generate_unique_user_id, validate_username, is_valid_phone_number from server.utils.user_utils import generate_unique_user_id, validate_username, is_valid_phone_number
from server.utils.common_utils import log_operation from server.utils.common_utils import log_operation
@ -769,3 +770,58 @@ async def upload_user_avatar(
except Exception as e: except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"头像上传失败: {str(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> <template>
<div :class="['log-viewer', { fullscreen: state.isFullscreen }]" ref="logViewer"> <a-modal
<div class="control-panel"> v-model:open="showModal"
<div class="button-group"> title="调试面板(请在生产环境中谨慎使用)"
<a-button width="90%"
@click="fetchLogs" :footer="null"
:loading="state.fetching" :maskClosable="true"
:icon="h(ReloadOutlined)" :destroyOnClose="true"
class="icon-only" class="debug-modal"
> >
</a-button> <div :class="['log-viewer', { fullscreen: state.isFullscreen }]" ref="logViewer">
<a-button @click="clearLogs" :icon="h(ClearOutlined)" class="icon-only"> </a-button> <div class="control-panel">
<a-button @click="printSystemConfig"> <div class="button-group">
<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 <a-button
:type="state.autoRefresh ? 'primary' : 'default'" @click="fetchLogs"
:class="{ 'auto-refresh-button': state.autoRefresh }" :loading="state.fetching"
@click="toggleAutoRefresh(!state.autoRefresh)" :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-button>
</a-tooltip> <a-button @click="clearLogs" :icon="h(ClearOutlined)" class="icon-only"> </a-button>
</div> <a-button @click="printSystemConfig">
<div class="filter-group"> <template #icon><SettingOutlined /></template>
<a-input-search 系统配置
v-model:value="state.searchText" </a-button>
placeholder="搜索日志..." <a-button @click="printUserInfo">
style="width: 200px; height: 32px" <template #icon><UserOutlined /></template>
@search="onSearch" 用户信息
/> </a-button>
<div class="log-level-selector"> <a-button @click="printDatabaseInfo">
<div class="multi-select-cards"> <template #icon><DatabaseOutlined /></template>
<div 知识库信息
v-for="level in logLevels" </a-button>
:key="level.value" <a-button @click="printAgentConfig">
class="option-card" <template #icon><RobotOutlined /></template>
:class="{ 智能体配置
selected: isLogLevelSelected(level.value), </a-button>
unselected: !isLogLevelSelected(level.value) <a-button @click="toggleDebugMode" :type="infoStore.debugMode ? 'primary' : 'default'">
}" <template #icon><BugOutlined /></template>
@click="toggleLogLevel(level.value)" 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"> <template #icon>
<span class="option-text">{{ level.label }}</span> <SyncOutlined :spin="state.autoRefresh" />
<div class="option-indicator"> </template>
<CheckCircleOutlined v-if="isLogLevelSelected(level.value)" /> 自动刷新
<PlusCircleOutlined v-else /> <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>
</div> </div>
</div> </div>
</div> <div ref="logContainer" class="log-container">
<div ref="logContainer" class="log-container"> <div v-if="processedLogs.length" class="log-lines">
<div v-if="processedLogs.length" class="log-lines"> <div
<div v-for="(log, index) in processedLogs"
v-for="(log, index) in processedLogs" :key="index"
:key="index" :class="['log-line', `level-${log.level.toLowerCase()}`]"
:class="['log-line', `level-${log.level.toLowerCase()}`]" >
> <span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span> <span class="level">{{ log.level }}</span>
<span class="level">{{ log.level }}</span> <span class="module">{{ log.module }}</span>
<span class="module">{{ log.module }}</span> <span class="message">{{ log.message }}</span>
<span class="message">{{ log.message }}</span> </div>
</div> </div>
<div v-else class="empty-logs">暂无日志</div>
</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> </div>
<p v-if="error" class="error">{{ error }}</p> </a-modal>
</div>
</template> </template>
<script setup> <script setup>
import { import { ref, reactive, computed, defineModel, onMounted, onActivated, onUnmounted, nextTick, toRaw, h } from 'vue'
ref,
onMounted, const showModal = defineModel('show')
onActivated,
onUnmounted,
nextTick,
reactive,
computed,
h,
toRaw
} from 'vue'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useDatabaseStore } from '@/stores/database' import { useDatabaseStore } from '@/stores/database'
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info' import { useInfoStore } from '@/stores/info'
import { useThrottleFn } from '@vueuse/core' 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 { import {
FullscreenOutlined, FullscreenOutlined,
FullscreenExitOutlined, FullscreenExitOutlined,
@ -132,11 +158,12 @@ import {
UserOutlined, UserOutlined,
DatabaseOutlined, DatabaseOutlined,
RobotOutlined, RobotOutlined,
BugOutlined BugOutlined,
SwapOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import dayjs from '@/utils/time' import dayjs from '@/utils/time'
import { configApi } from '@/apis/system_api' import { configApi } from '@/apis/system_api'
import { checkAdminPermission } from '@/stores/user' import { checkSuperAdminPermission } from '@/stores/user'
const configStore = useConfigStore() const configStore = useConfigStore()
const userStore = useUserStore() const userStore = useUserStore()
@ -162,7 +189,10 @@ const state = reactive({
searchText: '', searchText: '',
selectedLevels: logLevels.map((l) => l.value), selectedLevels: logLevels.map((l) => l.value),
rawLogs: [], rawLogs: [],
isFullscreen: false isFullscreen: false,
showUserSwitcher: false,
users: [],
switchingUser: false
}) })
const error = ref('') const error = ref('')
@ -219,7 +249,7 @@ const processedLogs = computed(() => {
// //
const fetchLogs = async () => { const fetchLogs = async () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
state.fetching = true state.fetching = true
try { try {
@ -245,7 +275,7 @@ const fetchLogs = async () => {
// //
const clearLogs = () => { const clearLogs = () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
state.rawLogs = [] state.rawLogs = []
} }
@ -280,7 +310,7 @@ const toggleLogLevel = (level) => {
// //
const toggleAutoRefresh = (value) => { const toggleAutoRefresh = (value) => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
if (value) { if (value) {
autoRefreshInterval = setInterval(fetchLogs, 5000) autoRefreshInterval = setInterval(fetchLogs, 5000)
@ -296,7 +326,7 @@ const toggleAutoRefresh = (value) => {
// //
const toggleFullscreen = async () => { const toggleFullscreen = async () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
try { try {
if (!state.isFullscreen) { if (!state.isFullscreen) {
@ -329,7 +359,7 @@ const handleFullscreenChange = () => {
} }
onMounted(() => { onMounted(() => {
if (checkAdminPermission()) { if (checkSuperAdminPermission()) {
fetchLogs() fetchLogs()
} }
document.addEventListener('fullscreenchange', handleFullscreenChange) document.addEventListener('fullscreenchange', handleFullscreenChange)
@ -338,7 +368,7 @@ onMounted(() => {
}) })
onActivated(() => { onActivated(() => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
if (state.autoRefresh) { if (state.autoRefresh) {
toggleAutoRefresh(true) toggleAutoRefresh(true)
@ -359,14 +389,14 @@ onUnmounted(() => {
// //
const printSystemConfig = () => { const printSystemConfig = () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
console.log('=== 系统配置 ===') console.log('=== 系统配置 ===')
console.log(config) console.log(config)
} }
// //
const printUserInfo = () => { const printUserInfo = () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
console.log('=== 用户信息 ===') console.log('=== 用户信息 ===')
const userInfo = { const userInfo = {
token: userStore.token ? '*** (已隐藏)' : null, token: userStore.token ? '*** (已隐藏)' : null,
@ -385,7 +415,7 @@ const printUserInfo = () => {
// //
const printDatabaseInfo = async () => { const printDatabaseInfo = async () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
try { try {
console.log('=== 知识库信息 ===') console.log('=== 知识库信息 ===')
@ -418,13 +448,13 @@ const printDatabaseInfo = async () => {
// Debug // Debug
const toggleDebugMode = () => { const toggleDebugMode = () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
infoStore.toggleDebugMode() infoStore.toggleDebugMode()
} }
// //
const printAgentConfig = async () => { const printAgentConfig = async () => {
if (!checkAdminPermission()) return if (!checkSuperAdminPermission()) return
try { try {
console.log('=== 智能体配置信息 ===') console.log('=== 智能体配置信息 ===')
@ -486,6 +516,66 @@ const printAgentConfig = async () => {
message.error('获取智能体配置失败: ' + error.message) 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> </script>
<style scoped> <style scoped>

View File

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

View File

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