mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 实现功能模块系统,支持模块的动态注册与启用控制
- 新增 ModuleRegistry 核心,支持模块注册、可用性检查和启用状态管理 - 新增模块管理 API(管理端和公共端),提供模块状态查询和启用控制 - 将 LDAP 功能迁移到模块系统,支持通过环境变量和数据库配置控制 - 前端新增模块管理页面和 store,支持模块启用/禁用操作 - 路由守卫集成模块状态检查,未激活模块的页面自动重定向 - 面包屑导航支持模块配置页面的层级显示 - Switch 组件新增 disabled 属性支持
This commit is contained in:
63
frontend/src/api/modules.ts
Normal file
63
frontend/src/api/modules.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface ModuleStatus {
|
||||
name: string
|
||||
available: boolean
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
display_name: string
|
||||
description: string
|
||||
category: 'auth' | 'monitoring' | 'security' | 'integration'
|
||||
admin_route: string | null
|
||||
admin_menu_icon: string | null
|
||||
admin_menu_group: string | null
|
||||
admin_menu_order: number
|
||||
health: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
|
||||
}
|
||||
|
||||
export interface AuthModuleInfo {
|
||||
name: string
|
||||
display_name: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const modulesApi = {
|
||||
/**
|
||||
* 获取所有模块状态(管理员)
|
||||
*/
|
||||
async getAllStatus(): Promise<Record<string, ModuleStatus>> {
|
||||
const response = await apiClient.get<Record<string, ModuleStatus>>(
|
||||
'/api/admin/modules/status'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个模块状态(管理员)
|
||||
*/
|
||||
async getStatus(moduleName: string): Promise<ModuleStatus> {
|
||||
const response = await apiClient.get<ModuleStatus>(
|
||||
`/api/admin/modules/status/${moduleName}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置模块启用状态(管理员)
|
||||
*/
|
||||
async setEnabled(moduleName: string, enabled: boolean): Promise<ModuleStatus> {
|
||||
const response = await apiClient.put<ModuleStatus>(
|
||||
`/api/admin/modules/status/${moduleName}/enabled`,
|
||||
{ enabled }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取认证模块状态(公开接口,供登录页使用)
|
||||
*/
|
||||
async getAuthModulesStatus(): Promise<AuthModuleInfo[]> {
|
||||
const response = await apiClient.get<AuthModuleInfo[]>('/api/modules/auth-status')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:disabled="disabled"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="[
|
||||
modelValue ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
@@ -21,6 +22,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -245,9 +245,24 @@
|
||||
<header class="hidden lg:flex h-16 px-8 items-center justify-between shrink-0 border-b border-[#3d3929]/5 dark:border-white/5 sticky top-0 z-40 backdrop-blur-md bg-[#faf9f5]/90 dark:bg-[#191714]/90">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{{ currentSectionName }}</span>
|
||||
<ChevronRight class="w-3 h-3 opacity-50" />
|
||||
<span class="text-foreground font-medium">{{ currentPageName }}</span>
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||
<template v-if="index > 0">
|
||||
<ChevronRight class="w-3 h-3 opacity-50" />
|
||||
</template>
|
||||
<RouterLink
|
||||
v-if="crumb.href && index < breadcrumbs.length - 1"
|
||||
:to="crumb.href"
|
||||
class="hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-else
|
||||
:class="index === breadcrumbs.length - 1 ? 'text-foreground font-medium' : ''"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,6 +328,7 @@
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
||||
@@ -345,12 +361,14 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
Puzzle,
|
||||
} from 'lucide-vue-next'
|
||||
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const moduleStore = useModuleStore()
|
||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
|
||||
@@ -415,6 +433,11 @@ onMounted(() => {
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
|
||||
if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) {
|
||||
moduleStore.fetchModules()
|
||||
}
|
||||
|
||||
// 延迟检查更新,避免影响页面加载
|
||||
setTimeout(() => {
|
||||
checkForUpdate()
|
||||
@@ -473,6 +496,19 @@ const navigation = computed(() => {
|
||||
}
|
||||
]
|
||||
|
||||
// 系统菜单项(静态部分)
|
||||
const systemItems = [
|
||||
{ name: '公告管理', href: '/admin/announcements', icon: Megaphone },
|
||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||
]
|
||||
|
||||
// 模块管理和系统设置放在最后
|
||||
systemItems.push({ name: '模块管理', href: '/admin/modules', icon: Puzzle })
|
||||
systemItems.push({ name: '系统设置', href: '/admin/system', icon: Cog })
|
||||
|
||||
const adminNavigation = [
|
||||
{
|
||||
title: '概览',
|
||||
@@ -494,46 +530,52 @@ const navigation = computed(() => {
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ name: '公告管理', href: '/admin/announcements', icon: Megaphone },
|
||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||
{ name: 'LDAP 配置', href: '/admin/ldap', icon: Shield },
|
||||
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||
]
|
||||
items: systemItems
|
||||
}
|
||||
]
|
||||
|
||||
return authStore.user?.role === 'admin' ? adminNavigation : baseNavigation
|
||||
})
|
||||
|
||||
// Dynamic Header Title
|
||||
const currentSectionName = computed(() => {
|
||||
// Special case: personal settings page accessed by admin
|
||||
if (route.path === '/dashboard/settings') {
|
||||
return '账户'
|
||||
}
|
||||
// Find the group that contains the active item
|
||||
for (const group of navigation.value) {
|
||||
const hasActiveItem = group.items.some(item => isNavActive(item.href))
|
||||
if (hasActiveItem) {
|
||||
return group.title || ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
// Breadcrumbs
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const currentPageName = computed(() => {
|
||||
// Special case: personal settings page accessed by admin
|
||||
if (route.path === '/dashboard/settings') {
|
||||
return '个人设置'
|
||||
const breadcrumbs = computed((): BreadcrumbItem[] => {
|
||||
// Special case: personal settings page accessed by admin
|
||||
if (route.path === '/dashboard/settings') {
|
||||
return [
|
||||
{ label: '账户' },
|
||||
{ label: '个人设置' }
|
||||
]
|
||||
}
|
||||
|
||||
// Special case: module config pages (e.g., /admin/ldap)
|
||||
if (route.meta?.module) {
|
||||
const moduleName = route.meta.module as string
|
||||
const moduleStatus = moduleStore.modules[moduleName]
|
||||
const displayName = moduleStatus?.display_name || moduleName
|
||||
return [
|
||||
{ label: '系统' },
|
||||
{ label: '模块管理', href: '/admin/modules' },
|
||||
{ label: displayName }
|
||||
]
|
||||
}
|
||||
|
||||
// Find section and page from navigation
|
||||
for (const group of navigation.value) {
|
||||
const activeItem = group.items.find(item => isNavActive(item.href))
|
||||
if (activeItem) {
|
||||
return [
|
||||
{ label: group.title || '' },
|
||||
{ label: activeItem.name }
|
||||
]
|
||||
}
|
||||
// Flatten navigation to find matching item name
|
||||
const allItems = navigation.value.flatMap(group => group.items)
|
||||
const active = allItems.find(item => isNavActive(item.href))
|
||||
return active ? active.name : route.name?.toString() || '仪表盘'
|
||||
}
|
||||
|
||||
return [{ label: '仪表盘' }]
|
||||
})
|
||||
|
||||
// Styling Classes (Editorial)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { importWithRetry } from '@/utils/importRetry'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
@@ -116,6 +117,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'SystemSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'ModuleManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
@@ -124,7 +130,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'ldap',
|
||||
name: 'LdapSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue'))
|
||||
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue')),
|
||||
meta: { module: 'ldap' }
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
@@ -164,6 +171,7 @@ function isNetworkError(error: any): boolean {
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const moduleStore = useModuleStore()
|
||||
|
||||
try {
|
||||
// 如果有token但没有用户信息,尝试获取用户信息
|
||||
@@ -186,6 +194,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
// 检查整个路由匹配记录链中的 meta
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)
|
||||
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
|
||||
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as string | undefined
|
||||
|
||||
// 如果需要认证但没有token,跳转到首页
|
||||
if (requiresAuth && !authStore.token) {
|
||||
@@ -212,6 +221,26 @@ router.beforeEach(async (to, from, next) => {
|
||||
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
|
||||
next('/dashboard')
|
||||
} else {
|
||||
// 检查模块可用性
|
||||
if (moduleName) {
|
||||
// 确保模块状态已加载
|
||||
if (!moduleStore.loaded) {
|
||||
try {
|
||||
await moduleStore.fetchModules()
|
||||
} catch (error) {
|
||||
// fail-close: 获取模块状态失败时拒绝访问
|
||||
log.warn('Failed to fetch modules status, denying access', { error })
|
||||
next('/admin/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
// 如果模块未激活(available && enabled),重定向到管理员首页
|
||||
if (!moduleStore.isActive(moduleName)) {
|
||||
log.warn(`Module ${moduleName} is not active, redirecting to admin dashboard`)
|
||||
next('/admin/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
} else {
|
||||
|
||||
109
frontend/src/stores/modules.ts
Normal file
109
frontend/src/stores/modules.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { modulesApi, type ModuleStatus } from '@/api/modules'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
export const useModuleStore = defineStore('modules', () => {
|
||||
const modules = ref<Record<string, ModuleStatus>>({})
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 获取所有模块状态
|
||||
*/
|
||||
async function fetchModules() {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
modules.value = await modulesApi.getAllStatus()
|
||||
loaded.value = true
|
||||
} catch (err: any) {
|
||||
log.error('Failed to fetch modules status', err)
|
||||
error.value = err.response?.data?.detail || '获取模块状态失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块是否部署可用
|
||||
*/
|
||||
function isAvailable(moduleName: string): boolean {
|
||||
return modules.value[moduleName]?.available ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块是否运行启用
|
||||
*/
|
||||
function isEnabled(moduleName: string): boolean {
|
||||
return modules.value[moduleName]?.enabled ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块是否最终激活
|
||||
*/
|
||||
function isActive(moduleName: string): boolean {
|
||||
return modules.value[moduleName]?.active ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置模块启用状态
|
||||
*/
|
||||
async function setEnabled(moduleName: string, enabled: boolean) {
|
||||
try {
|
||||
await modulesApi.setEnabled(moduleName, enabled)
|
||||
// 刷新所有模块状态,确保依赖模块的 active 状态同步更新
|
||||
await fetchModules()
|
||||
return true
|
||||
} catch (err: any) {
|
||||
log.error(`Failed to set module ${moduleName} enabled=${enabled}`, err)
|
||||
error.value = err.response?.data?.detail || '设置模块状态失败'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的管理菜单项(available 即显示)
|
||||
*/
|
||||
const availableAdminMenuItems = computed(() => {
|
||||
return Object.values(modules.value)
|
||||
.filter((m) => m.available && m.admin_route)
|
||||
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
|
||||
})
|
||||
|
||||
/**
|
||||
* 按分组获取可用的管理菜单项
|
||||
*/
|
||||
const availableAdminMenuItemsByGroup = computed(() => {
|
||||
const items = availableAdminMenuItems.value
|
||||
const groups: Record<string, ModuleStatus[]> = {}
|
||||
|
||||
for (const item of items) {
|
||||
const group = item.admin_menu_group || 'other'
|
||||
if (!groups[group]) {
|
||||
groups[group] = []
|
||||
}
|
||||
groups[group].push(item)
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
return {
|
||||
modules,
|
||||
loaded,
|
||||
loading,
|
||||
error,
|
||||
fetchModules,
|
||||
isAvailable,
|
||||
isEnabled,
|
||||
isActive,
|
||||
setEnabled,
|
||||
availableAdminMenuItems,
|
||||
availableAdminMenuItemsByGroup,
|
||||
}
|
||||
})
|
||||
269
frontend/src/views/admin/ModuleManagement.vue
Normal file
269
frontend/src/views/admin/ModuleManagement.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="模块管理"
|
||||
description="管理系统功能模块的启用状态"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="fetchModules"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4 mr-2" :class="{ 'animate-spin': loading }" />
|
||||
刷新
|
||||
</Button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="mt-6 mb-6">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索模块名称或描述..."
|
||||
class="pl-11 h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- 模块卡片网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<div
|
||||
v-for="module in filteredModules"
|
||||
:key="module.name"
|
||||
class="group relative border rounded-2xl p-6 transition-all duration-200 hover:shadow-lg"
|
||||
:class="{
|
||||
'bg-muted/40 border-muted': !module.available,
|
||||
'border-primary/40 bg-gradient-to-br from-primary/5 to-primary/10 shadow-sm': module.active,
|
||||
'border-border bg-card hover:border-primary/20': !module.active && module.available
|
||||
}"
|
||||
>
|
||||
<!-- 状态指示器 -->
|
||||
<div class="absolute top-5 right-5">
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full ring-2 ring-offset-2 ring-offset-background"
|
||||
:class="{
|
||||
'bg-green-500 ring-green-500/30': module.active,
|
||||
'bg-amber-500 ring-amber-500/30': module.available && module.enabled && !module.active,
|
||||
'bg-gray-300 ring-gray-300/30': module.available && !module.enabled,
|
||||
'bg-red-400 ring-red-400/30': !module.available
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模块图标和名称 -->
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-colors"
|
||||
:class="module.active
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-muted/80'"
|
||||
>
|
||||
<component :is="getCategoryIcon(module.category)" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 pt-0.5">
|
||||
<h4 class="font-semibold text-base truncate">{{ module.display_name }}</h4>
|
||||
<div class="mt-1.5">
|
||||
<Badge
|
||||
:variant="getStatusBadgeVariant(module)"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ getStatusText(module) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p class="text-sm text-muted-foreground leading-relaxed line-clamp-2 min-h-[2.5rem]">
|
||||
{{ module.description }}
|
||||
</p>
|
||||
|
||||
<!-- 模块信息 -->
|
||||
<div class="mt-4 pt-4 border-t border-border/50 space-y-2">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="font-mono bg-muted/50 px-1.5 py-0.5 rounded">{{ module.name }}</span>
|
||||
<span class="text-border">|</span>
|
||||
<span :class="{
|
||||
'text-green-600': module.health === 'healthy',
|
||||
'text-amber-600': module.health === 'degraded',
|
||||
'text-red-600': module.health === 'unhealthy',
|
||||
}">
|
||||
{{ getHealthText(module.health) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 不可用提示 -->
|
||||
<div
|
||||
v-if="!module.available"
|
||||
class="mt-4 text-xs text-orange-700 dark:text-orange-400 bg-orange-100 dark:bg-orange-950/50 rounded-lg px-3 py-2"
|
||||
>
|
||||
模块不可用,请检查环境变量和依赖库
|
||||
</div>
|
||||
|
||||
<!-- 操作区域 -->
|
||||
<div class="mt-5 pt-4 border-t border-border/50 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
:model-value="module.enabled"
|
||||
:disabled="!module.available || toggling[module.name]"
|
||||
@update:model-value="(val: boolean) => toggleModule(module.name, val)"
|
||||
/>
|
||||
<span class="text-sm" :class="module.enabled ? 'text-foreground' : 'text-muted-foreground'">
|
||||
{{ module.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="module.admin_route && module.active"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="router.push(module.admin_route)"
|
||||
>
|
||||
<Settings class="w-3.5 h-3.5" />
|
||||
配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索无结果 -->
|
||||
<div
|
||||
v-if="filteredModules.length === 0 && searchQuery && !loading"
|
||||
class="text-center py-16"
|
||||
>
|
||||
<Search class="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p class="text-muted-foreground">没有找到匹配的模块</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="allModules.length === 0 && !loading"
|
||||
class="text-center py-16"
|
||||
>
|
||||
<Puzzle class="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p class="text-muted-foreground">暂无可管理的模块</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RefreshCw, Puzzle, Users, Shield, Gauge, Link, Search, Settings } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import { PageHeader, PageContainer } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import type { ModuleStatus } from '@/api/modules'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const router = useRouter()
|
||||
const { success, error } = useToast()
|
||||
const moduleStore = useModuleStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const toggling = ref<Record<string, boolean>>({})
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 获取分类图标
|
||||
function getCategoryIcon(category: string) {
|
||||
const icons: Record<string, any> = {
|
||||
auth: Users,
|
||||
monitoring: Gauge,
|
||||
security: Shield,
|
||||
integration: Link,
|
||||
}
|
||||
return icons[category] || Puzzle
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
function getStatusText(module: ModuleStatus): string {
|
||||
if (!module.available) return '不可用'
|
||||
if (module.active) return '已激活'
|
||||
if (module.enabled) return '已启用'
|
||||
return '已禁用'
|
||||
}
|
||||
|
||||
// 获取状态徽章样式
|
||||
function getStatusBadgeVariant(module: ModuleStatus): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
if (!module.available) return 'destructive'
|
||||
if (module.active) return 'default'
|
||||
if (module.enabled) return 'secondary'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
// 获取健康状态文本
|
||||
function getHealthText(health: string): string {
|
||||
const texts: Record<string, string> = {
|
||||
healthy: '健康',
|
||||
degraded: '降级',
|
||||
unhealthy: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
return texts[health] || health
|
||||
}
|
||||
|
||||
// 所有模块列表(按 admin_menu_order 排序)
|
||||
const allModules = computed(() => {
|
||||
return Object.values(moduleStore.modules)
|
||||
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
|
||||
})
|
||||
|
||||
// 过滤后的模块列表
|
||||
const filteredModules = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return allModules.value
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return allModules.value.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name.toLowerCase().includes(query) ||
|
||||
m.description.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// 获取模块列表
|
||||
async function fetchModules() {
|
||||
loading.value = true
|
||||
try {
|
||||
await moduleStore.fetchModules()
|
||||
} catch (err) {
|
||||
error('获取模块列表失败')
|
||||
log.error('获取模块列表失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模块启用状态
|
||||
async function toggleModule(moduleName: string, enabled: boolean) {
|
||||
toggling.value[moduleName] = true
|
||||
try {
|
||||
const result = await moduleStore.setEnabled(moduleName, enabled)
|
||||
if (result) {
|
||||
success(enabled ? '模块已启用' : '模块已禁用')
|
||||
} else {
|
||||
error('操作失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error('操作失败')
|
||||
log.error('切换模块状态失败:', err)
|
||||
} finally {
|
||||
toggling.value[moduleName] = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchModules()
|
||||
})
|
||||
</script>
|
||||
@@ -5,8 +5,8 @@ from fastapi import APIRouter
|
||||
from .adaptive import router as adaptive_router
|
||||
from .api_keys import router as api_keys_router
|
||||
from .endpoints import router as endpoints_router
|
||||
from .ldap import router as ldap_router
|
||||
from .management_tokens import router as management_tokens_router
|
||||
from .modules import router as modules_router
|
||||
from .models import router as models_router
|
||||
from .monitoring import router as monitoring_router
|
||||
from .provider_query import router as provider_query_router
|
||||
@@ -30,7 +30,10 @@ router.include_router(adaptive_router)
|
||||
router.include_router(models_router)
|
||||
router.include_router(security_router)
|
||||
router.include_router(provider_query_router)
|
||||
router.include_router(ldap_router)
|
||||
router.include_router(management_tokens_router)
|
||||
router.include_router(modules_router)
|
||||
|
||||
# 注意:ldap_router 已迁移到模块系统,由 ModuleRegistry 动态注册
|
||||
# 当 LDAP_AVAILABLE=true 时才会注册路由
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
189
src/api/admin/modules.py
Normal file
189
src/api/admin/modules.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""模块管理 API 端点"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.modules import ModuleStatus, get_module_registry
|
||||
from src.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/admin/modules", tags=["Admin - Modules"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
# ========== Response Models ==========
|
||||
|
||||
|
||||
class ModuleStatusResponse(BaseModel):
|
||||
"""模块状态响应"""
|
||||
|
||||
name: str
|
||||
available: bool
|
||||
enabled: bool
|
||||
active: bool
|
||||
display_name: str
|
||||
description: str
|
||||
category: str
|
||||
admin_route: Optional[str]
|
||||
admin_menu_icon: Optional[str]
|
||||
admin_menu_group: Optional[str]
|
||||
admin_menu_order: int
|
||||
health: str
|
||||
|
||||
@classmethod
|
||||
def from_status(cls, status: ModuleStatus) -> "ModuleStatusResponse":
|
||||
return cls(
|
||||
name=status.name,
|
||||
available=status.available,
|
||||
enabled=status.enabled,
|
||||
active=status.active,
|
||||
display_name=status.display_name,
|
||||
description=status.description,
|
||||
category=status.category.value,
|
||||
admin_route=status.admin_route,
|
||||
admin_menu_icon=status.admin_menu_icon,
|
||||
admin_menu_group=status.admin_menu_group,
|
||||
admin_menu_order=status.admin_menu_order,
|
||||
health=status.health.value,
|
||||
)
|
||||
|
||||
|
||||
class SetModuleEnabledRequest(BaseModel):
|
||||
"""设置模块启用状态请求"""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
# ========== API Endpoints ==========
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_all_modules_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取所有模块状态
|
||||
|
||||
返回系统中所有已注册模块的状态信息,包括可用性、启用状态等。
|
||||
需要管理员权限。
|
||||
|
||||
**返回字段**:
|
||||
- 模块名称到状态的映射字典
|
||||
"""
|
||||
adapter = AdminGetAllModulesStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/status/{module_name}")
|
||||
async def get_module_status(
|
||||
module_name: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取单个模块状态
|
||||
|
||||
获取指定模块的详细状态信息。需要管理员权限。
|
||||
|
||||
**路径参数**:
|
||||
- `module_name`: 模块名称
|
||||
|
||||
**返回字段**:
|
||||
- 模块状态详情
|
||||
"""
|
||||
adapter = AdminGetModuleStatusAdapter(module_name=module_name)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/status/{module_name}/enabled")
|
||||
async def set_module_enabled(
|
||||
module_name: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
设置模块启用状态
|
||||
|
||||
启用或禁用指定模块。需要管理员权限。
|
||||
注意:只有 available=true 的模块才能被启用。
|
||||
|
||||
**路径参数**:
|
||||
- `module_name`: 模块名称
|
||||
|
||||
**请求体**:
|
||||
- `enabled`: 是否启用
|
||||
|
||||
**返回字段**:
|
||||
- 更新后的模块状态
|
||||
"""
|
||||
adapter = AdminSetModuleEnabledAdapter(module_name=module_name)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ========== Adapters ==========
|
||||
|
||||
|
||||
class AdminGetAllModulesStatusAdapter(AdminApiAdapter):
|
||||
"""获取所有模块状态"""
|
||||
|
||||
async def handle(self, context) -> Dict[str, Any]:
|
||||
registry = get_module_registry()
|
||||
all_status = await registry.get_all_status_async(context.db)
|
||||
|
||||
return {
|
||||
name: ModuleStatusResponse.from_status(status).model_dump()
|
||||
for name, status in all_status.items()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetModuleStatusAdapter(AdminApiAdapter):
|
||||
"""获取单个模块状态"""
|
||||
|
||||
module_name: str
|
||||
|
||||
async def handle(self, context) -> Dict[str, Any]:
|
||||
registry = get_module_registry()
|
||||
status = await registry.get_module_status_async(self.module_name, context.db)
|
||||
|
||||
if status is None:
|
||||
raise NotFoundException(f"模块 '{self.module_name}' 不存在")
|
||||
|
||||
return ModuleStatusResponse.from_status(status).model_dump()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminSetModuleEnabledAdapter(AdminApiAdapter):
|
||||
"""设置模块启用状态"""
|
||||
|
||||
module_name: str
|
||||
|
||||
async def handle(self, context) -> Dict[str, Any]:
|
||||
registry = get_module_registry()
|
||||
|
||||
# 检查模块是否存在
|
||||
module = registry.get_module(self.module_name)
|
||||
if module is None:
|
||||
raise NotFoundException(f"模块 '{self.module_name}' 不存在")
|
||||
|
||||
# 检查模块是否可用
|
||||
if not registry.is_available(self.module_name):
|
||||
raise InvalidRequestException(
|
||||
f"模块 '{self.module_name}' 不可用,无法启用。"
|
||||
f"请检查环境变量 {module.metadata.env_key} 和依赖库。"
|
||||
)
|
||||
|
||||
# 解析请求体
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = SetModuleEnabledRequest.model_validate(payload)
|
||||
except Exception:
|
||||
raise InvalidRequestException("请求体格式错误,需要 enabled 字段")
|
||||
|
||||
# 设置启用状态
|
||||
registry.set_enabled(self.module_name, req.enabled, context.db)
|
||||
|
||||
# 返回更新后的状态(模块已在上面检查存在,此处必定返回非 None)
|
||||
status = registry.get_module_status(self.module_name, context.db)
|
||||
assert status is not None
|
||||
return ModuleStatusResponse.from_status(status).model_dump()
|
||||
@@ -7,6 +7,7 @@ from .catalog import router as catalog_router
|
||||
from .claude import router as claude_router
|
||||
from .gemini import router as gemini_router
|
||||
from .models import router as models_router
|
||||
from .modules import router as modules_router
|
||||
from .openai import router as openai_router
|
||||
from .system_catalog import router as system_catalog_router
|
||||
|
||||
@@ -19,5 +20,6 @@ router.include_router(gemini_router, tags=["Gemini API"])
|
||||
router.include_router(system_catalog_router, tags=["System Catalog"])
|
||||
router.include_router(catalog_router)
|
||||
router.include_router(capabilities_router)
|
||||
router.include_router(modules_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
46
src/api/public/modules.py
Normal file
46
src/api/public/modules.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""公开模块状态 API(供登录页等使用)"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.modules import ModuleCategory, get_module_registry
|
||||
from src.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/modules", tags=["Modules"])
|
||||
|
||||
|
||||
class AuthModuleInfo(BaseModel):
|
||||
"""认证模块简要信息"""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
active: bool
|
||||
|
||||
|
||||
@router.get("/auth-status", response_model=List[AuthModuleInfo])
|
||||
async def get_auth_modules_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取认证模块状态(公开接口)
|
||||
|
||||
供登录页使用,返回所有可用的认证模块及其激活状态。
|
||||
不需要认证即可访问。
|
||||
|
||||
**返回字段**:
|
||||
- `name`: 模块名称
|
||||
- `display_name`: 显示名称
|
||||
- `active`: 是否激活
|
||||
"""
|
||||
registry = get_module_registry()
|
||||
auth_modules = registry.get_auth_modules_status(db)
|
||||
|
||||
return [
|
||||
AuthModuleInfo(
|
||||
name=status.name,
|
||||
display_name=status.display_name,
|
||||
active=status.active,
|
||||
)
|
||||
for status in auth_modules
|
||||
]
|
||||
26
src/core/modules/__init__.py
Normal file
26
src/core/modules/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
模块化系统核心
|
||||
|
||||
提供可扩展的功能模块管理,支持:
|
||||
- 声明式模块注册
|
||||
- available/enabled 双层状态控制
|
||||
- 延迟导入避免重依赖加载
|
||||
- 前后端状态同步
|
||||
"""
|
||||
|
||||
from src.core.modules.base import (
|
||||
ModuleCategory,
|
||||
ModuleDefinition,
|
||||
ModuleMetadata,
|
||||
ModuleStatus,
|
||||
)
|
||||
from src.core.modules.registry import ModuleRegistry, get_module_registry
|
||||
|
||||
__all__ = [
|
||||
"ModuleCategory",
|
||||
"ModuleMetadata",
|
||||
"ModuleDefinition",
|
||||
"ModuleStatus",
|
||||
"ModuleRegistry",
|
||||
"get_module_registry",
|
||||
]
|
||||
113
src/core/modules/base.py
Normal file
113
src/core/modules/base.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
模块基础定义
|
||||
|
||||
包含模块元数据、定义和状态的数据结构
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
class ModuleCategory(str, Enum):
|
||||
"""模块分类"""
|
||||
|
||||
AUTH = "auth" # 认证相关
|
||||
MONITORING = "monitoring" # 监控相关
|
||||
SECURITY = "security" # 安全相关
|
||||
INTEGRATION = "integration" # 第三方集成
|
||||
|
||||
|
||||
class ModuleHealth(str, Enum):
|
||||
"""模块健康状态"""
|
||||
|
||||
HEALTHY = "healthy"
|
||||
DEGRADED = "degraded"
|
||||
UNHEALTHY = "unhealthy"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleMetadata:
|
||||
"""
|
||||
模块元数据 - 纯数据描述,无重依赖
|
||||
|
||||
用于声明式定义模块的基本信息和配置
|
||||
"""
|
||||
|
||||
# 基本信息
|
||||
name: str # 唯一标识: ldap, audit_log
|
||||
display_name: str # 显示名称: "LDAP 认证"
|
||||
description: str # 模块描述
|
||||
|
||||
# 分类
|
||||
category: ModuleCategory
|
||||
|
||||
# 可用性控制(部署级)
|
||||
env_key: str # 环境变量名: LDAP_AVAILABLE
|
||||
default_available: bool = False # 默认是否可用
|
||||
required_packages: List[str] = field(default_factory=list) # 依赖的 Python 包
|
||||
dependencies: List[str] = field(default_factory=list) # 依赖的其他模块
|
||||
|
||||
# 路由配置 - 模块自定义前缀
|
||||
api_prefix: Optional[str] = None # 如 "/api/admin/ldap"
|
||||
|
||||
# 前端配置
|
||||
admin_route: Optional[str] = None # 管理页面路由: "/admin/ldap"
|
||||
admin_menu_icon: Optional[str] = None # 菜单图标
|
||||
admin_menu_group: Optional[str] = None # 菜单分组: "system", "security"
|
||||
admin_menu_order: int = 100 # 菜单排序(越小越靠前)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleDefinition:
|
||||
"""
|
||||
完整模块定义
|
||||
|
||||
包含元数据和生命周期钩子,钩子函数内部延迟导入重依赖
|
||||
"""
|
||||
|
||||
metadata: ModuleMetadata
|
||||
|
||||
# 工厂函数 - 内部再 import 重依赖
|
||||
router_factory: Optional[Callable[[], "APIRouter"]] = None
|
||||
service_factory: Optional[Callable[[], Any]] = None
|
||||
|
||||
# 生命周期钩子
|
||||
on_startup: Optional[Callable[[], Awaitable[None]]] = None
|
||||
on_shutdown: Optional[Callable[[], Awaitable[None]]] = None
|
||||
health_check: Optional[Callable[[], Awaitable[ModuleHealth]]] = None
|
||||
|
||||
# 自定义依赖检测(可选,用于检测 ldap3 等库是否安装)
|
||||
check_dependencies: Optional[Callable[[], bool]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleStatus:
|
||||
"""
|
||||
模块运行状态
|
||||
|
||||
用于 API 返回,供前端使用
|
||||
"""
|
||||
|
||||
name: str
|
||||
available: bool # 部署级可用(环境变量 + 依赖库)
|
||||
enabled: bool # 运行级启用(数据库配置)
|
||||
active: bool # 最终激活状态 (available && enabled && dependencies_ok)
|
||||
|
||||
# 显示信息
|
||||
display_name: str
|
||||
description: str
|
||||
category: ModuleCategory
|
||||
|
||||
# 前端配置
|
||||
admin_route: Optional[str]
|
||||
admin_menu_icon: Optional[str]
|
||||
admin_menu_group: Optional[str]
|
||||
admin_menu_order: int
|
||||
|
||||
# 健康状态
|
||||
health: ModuleHealth = ModuleHealth.UNKNOWN
|
||||
288
src/core/modules/registry.py
Normal file
288
src/core/modules/registry.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
模块注册中心
|
||||
|
||||
负责模块的注册、状态管理和生命周期控制
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.core.modules.base import (
|
||||
ModuleCategory,
|
||||
ModuleDefinition,
|
||||
ModuleHealth,
|
||||
ModuleStatus,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class ModuleRegistry:
|
||||
"""
|
||||
模块注册中心 - 单例模式
|
||||
|
||||
职责:
|
||||
- 注册模块定义(仅元数据,不加载重依赖)
|
||||
- 检查模块可用性(环境变量 + 依赖库)
|
||||
- 管理模块启用状态(数据库配置)
|
||||
- 提供模块状态查询
|
||||
"""
|
||||
|
||||
_instance: Optional["ModuleRegistry"] = None
|
||||
|
||||
def __init__(self):
|
||||
self._modules: Dict[str, ModuleDefinition] = {}
|
||||
self._initialized: Set[str] = set()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "ModuleRegistry":
|
||||
"""获取单例实例"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""重置单例(仅用于测试)"""
|
||||
cls._instance = None
|
||||
|
||||
def register(self, module: ModuleDefinition) -> None:
|
||||
"""
|
||||
注册模块
|
||||
|
||||
仅注册元数据,不加载重依赖
|
||||
"""
|
||||
name = module.metadata.name
|
||||
if name in self._modules:
|
||||
logger.warning(f"Module [{name}] already registered, skipping")
|
||||
return
|
||||
|
||||
self._modules[name] = module
|
||||
logger.debug(f"Module [{name}] registered")
|
||||
|
||||
def get_module(self, name: str) -> Optional[ModuleDefinition]:
|
||||
"""获取模块定义"""
|
||||
return self._modules.get(name)
|
||||
|
||||
def get_all_modules(self) -> List[ModuleDefinition]:
|
||||
"""获取所有已注册模块"""
|
||||
return list(self._modules.values())
|
||||
|
||||
# ========== 可用性检查(部署级)==========
|
||||
|
||||
def is_available(self, name: str) -> bool:
|
||||
"""
|
||||
检查模块是否部署可用
|
||||
|
||||
检查顺序:
|
||||
1. 模块是否已注册
|
||||
2. 环境变量是否启用
|
||||
3. 依赖的 Python 包是否安装
|
||||
4. 自定义依赖检测(如果有)
|
||||
"""
|
||||
if name not in self._modules:
|
||||
return False
|
||||
|
||||
module = self._modules[name]
|
||||
meta = module.metadata
|
||||
|
||||
# 1. 检查环境变量
|
||||
env_value = os.getenv(meta.env_key)
|
||||
if env_value is not None:
|
||||
if env_value.lower() not in ("true", "1", "yes"):
|
||||
return False
|
||||
elif not meta.default_available:
|
||||
return False
|
||||
|
||||
# 2. 检查依赖的 Python 包
|
||||
for pkg in meta.required_packages:
|
||||
if importlib.util.find_spec(pkg) is None:
|
||||
logger.debug(f"Module [{name}] unavailable: package '{pkg}' not installed")
|
||||
return False
|
||||
|
||||
# 3. 自定义依赖检测
|
||||
if module.check_dependencies:
|
||||
try:
|
||||
if not module.check_dependencies():
|
||||
logger.debug(f"Module [{name}] unavailable: custom dependency check failed")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Module [{name}] dependency check error: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_available_modules(self) -> List[ModuleDefinition]:
|
||||
"""获取所有部署可用的模块"""
|
||||
return [m for m in self._modules.values() if self.is_available(m.metadata.name)]
|
||||
|
||||
# ========== 启用状态检查(运行级)==========
|
||||
|
||||
def is_enabled(self, name: str, db: "Session") -> bool:
|
||||
"""
|
||||
检查模块是否运行启用(数据库配置)
|
||||
|
||||
Args:
|
||||
name: 模块名称
|
||||
db: 数据库会话
|
||||
"""
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
config_key = f"module.{name}.enabled"
|
||||
value = SystemConfigService.get_config(db, config_key, default=False)
|
||||
return bool(value)
|
||||
|
||||
def set_enabled(self, name: str, enabled: bool, db: "Session") -> None:
|
||||
"""
|
||||
设置模块启用状态
|
||||
|
||||
Args:
|
||||
name: 模块名称
|
||||
enabled: 是否启用
|
||||
db: 数据库会话
|
||||
"""
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
if name not in self._modules:
|
||||
raise ValueError(f"Module [{name}] not registered")
|
||||
|
||||
config_key = f"module.{name}.enabled"
|
||||
module = self._modules[name]
|
||||
description = f"模块 [{module.metadata.display_name}] 启用状态"
|
||||
SystemConfigService.set_config(db, config_key, enabled, description)
|
||||
|
||||
# ========== 激活状态检查 ==========
|
||||
|
||||
def is_active(self, name: str, db: "Session") -> bool:
|
||||
"""
|
||||
检查模块是否最终激活
|
||||
|
||||
激活条件:available && enabled && 依赖模块都激活
|
||||
"""
|
||||
if not self.is_available(name):
|
||||
return False
|
||||
if not self.is_enabled(name, db):
|
||||
return False
|
||||
|
||||
# 检查依赖模块
|
||||
module = self._modules[name]
|
||||
for dep in module.metadata.dependencies:
|
||||
if not self.is_active(dep, db):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# ========== 状态查询 ==========
|
||||
|
||||
def get_module_status(
|
||||
self, name: str, db: "Session", health: Optional[ModuleHealth] = None
|
||||
) -> Optional[ModuleStatus]:
|
||||
"""
|
||||
获取单个模块状态
|
||||
|
||||
Args:
|
||||
name: 模块名称
|
||||
db: 数据库会话
|
||||
health: 预先获取的健康状态(可选,用于异步场景)
|
||||
"""
|
||||
if name not in self._modules:
|
||||
return None
|
||||
|
||||
module = self._modules[name]
|
||||
meta = module.metadata
|
||||
available = self.is_available(name)
|
||||
|
||||
return ModuleStatus(
|
||||
name=name,
|
||||
available=available,
|
||||
enabled=self.is_enabled(name, db) if available else False,
|
||||
active=self.is_active(name, db) if available else False,
|
||||
display_name=meta.display_name,
|
||||
description=meta.description,
|
||||
category=meta.category,
|
||||
admin_route=meta.admin_route if available else None,
|
||||
admin_menu_icon=meta.admin_menu_icon,
|
||||
admin_menu_group=meta.admin_menu_group,
|
||||
admin_menu_order=meta.admin_menu_order,
|
||||
health=health if health else ModuleHealth.UNKNOWN,
|
||||
)
|
||||
|
||||
async def check_health(self, name: str) -> ModuleHealth:
|
||||
"""
|
||||
执行模块健康检查
|
||||
|
||||
Args:
|
||||
name: 模块名称
|
||||
|
||||
Returns:
|
||||
健康状态
|
||||
"""
|
||||
if name not in self._modules:
|
||||
return ModuleHealth.UNKNOWN
|
||||
|
||||
module = self._modules[name]
|
||||
if not module.health_check:
|
||||
return ModuleHealth.UNKNOWN
|
||||
|
||||
try:
|
||||
return await module.health_check()
|
||||
except Exception as e:
|
||||
logger.warning(f"Module [{name}] health check failed: {e}")
|
||||
return ModuleHealth.UNHEALTHY
|
||||
|
||||
async def get_module_status_async(
|
||||
self, name: str, db: "Session"
|
||||
) -> Optional[ModuleStatus]:
|
||||
"""异步获取模块状态(包含健康检查)"""
|
||||
if name not in self._modules:
|
||||
return None
|
||||
|
||||
health = await self.check_health(name) if self.is_available(name) else ModuleHealth.UNKNOWN
|
||||
return self.get_module_status(name, db, health=health)
|
||||
|
||||
async def get_all_status_async(self, db: "Session") -> Dict[str, ModuleStatus]:
|
||||
"""异步获取所有模块状态(包含健康检查)"""
|
||||
result = {}
|
||||
for name in self._modules:
|
||||
status = await self.get_module_status_async(name, db)
|
||||
if status:
|
||||
result[name] = status
|
||||
return result
|
||||
|
||||
def get_all_status(self, db: "Session") -> Dict[str, ModuleStatus]:
|
||||
"""获取所有模块状态(同步版本,不含健康检查)"""
|
||||
result = {}
|
||||
for name in self._modules:
|
||||
status = self.get_module_status(name, db)
|
||||
if status:
|
||||
result[name] = status
|
||||
return result
|
||||
|
||||
def get_available_status(self, db: "Session") -> Dict[str, ModuleStatus]:
|
||||
"""获取所有可用模块的状态"""
|
||||
result = {}
|
||||
for name, module in self._modules.items():
|
||||
if self.is_available(name):
|
||||
status = self.get_module_status(name, db)
|
||||
if status:
|
||||
result[name] = status
|
||||
return result
|
||||
|
||||
def get_auth_modules_status(self, db: "Session") -> List[ModuleStatus]:
|
||||
"""获取认证模块状态(供登录页使用)"""
|
||||
result = []
|
||||
for name, module in self._modules.items():
|
||||
if module.metadata.category == ModuleCategory.AUTH:
|
||||
if self.is_available(name):
|
||||
status = self.get_module_status(name, db)
|
||||
if status:
|
||||
result.append(status)
|
||||
return result
|
||||
|
||||
|
||||
def get_module_registry() -> ModuleRegistry:
|
||||
"""获取模块注册中心实例"""
|
||||
return ModuleRegistry.get_instance()
|
||||
31
src/main.py
31
src/main.py
@@ -24,6 +24,7 @@ from src.clients.http_client import HTTPClientPool, close_http_clients
|
||||
from src.config import config
|
||||
from src.core.exceptions import ExceptionHandlers, ProxyException
|
||||
from src.core.logger import logger
|
||||
from src.core.modules import get_module_registry
|
||||
from src.database import init_db
|
||||
|
||||
from src.middleware.plugin_middleware import PluginMiddleware
|
||||
@@ -159,6 +160,30 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
register_all_converters()
|
||||
|
||||
# 初始化功能模块系统
|
||||
logger.info("初始化功能模块系统...")
|
||||
from src.modules import ALL_MODULES
|
||||
|
||||
module_registry = get_module_registry()
|
||||
for module in ALL_MODULES:
|
||||
module_registry.register(module)
|
||||
|
||||
# 注册可用模块的路由
|
||||
# 注意:模块的 router 自带 prefix,api_prefix 字段仅用于日志和文档
|
||||
available_modules = module_registry.get_available_modules()
|
||||
for module in available_modules:
|
||||
if module.router_factory:
|
||||
router = module.router_factory()
|
||||
app.include_router(router)
|
||||
prefix = module.metadata.api_prefix or "(default)"
|
||||
logger.info(f"模块 [{module.metadata.name}] 路由已注册: {prefix}")
|
||||
|
||||
# 执行启动钩子
|
||||
if module.on_startup:
|
||||
await module.on_startup()
|
||||
|
||||
logger.info(f"功能模块初始化完成: {len(available_modules)}/{len(ALL_MODULES)} 个模块可用")
|
||||
|
||||
logger.info(f"服务启动成功: http://{config.host}:{config.port}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
@@ -250,6 +275,12 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("关闭插件系统...")
|
||||
await plugin_manager.shutdown_all()
|
||||
|
||||
# 关闭功能模块
|
||||
logger.info("关闭功能模块...")
|
||||
for module in available_modules:
|
||||
if module.on_shutdown:
|
||||
await module.on_shutdown()
|
||||
|
||||
# 关闭并发管理器
|
||||
logger.info("关闭并发管理器...")
|
||||
if concurrency_manager:
|
||||
|
||||
19
src/modules/__init__.py
Normal file
19
src/modules/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
功能模块注册
|
||||
|
||||
所有可选功能模块在此注册
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from src.core.modules.base import ModuleDefinition
|
||||
|
||||
# 导入所有模块定义
|
||||
from src.modules.ldap import ldap_module
|
||||
|
||||
# 所有模块列表
|
||||
ALL_MODULES: List[ModuleDefinition] = [
|
||||
ldap_module,
|
||||
]
|
||||
|
||||
__all__ = ["ALL_MODULES"]
|
||||
50
src/modules/ldap/__init__.py
Normal file
50
src/modules/ldap/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
LDAP 认证模块
|
||||
|
||||
提供 LDAP/Active Directory 用户认证支持
|
||||
"""
|
||||
|
||||
from src.core.modules.base import (
|
||||
ModuleCategory,
|
||||
ModuleDefinition,
|
||||
ModuleHealth,
|
||||
ModuleMetadata,
|
||||
)
|
||||
|
||||
|
||||
def _get_router():
|
||||
"""延迟导入路由(避免启动时加载重依赖)"""
|
||||
from src.api.admin.ldap import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
async def _health_check() -> ModuleHealth:
|
||||
"""健康检查 - 简化版,不依赖数据库连接"""
|
||||
# 健康检查在启动时调用,此时可能没有数据库会话
|
||||
# 返回 UNKNOWN 表示需要进一步检查
|
||||
return ModuleHealth.UNKNOWN
|
||||
|
||||
|
||||
# LDAP 模块定义
|
||||
ldap_module = ModuleDefinition(
|
||||
metadata=ModuleMetadata(
|
||||
name="ldap",
|
||||
display_name="LDAP 认证",
|
||||
description="支持通过 LDAP/Active Directory 进行用户认证",
|
||||
category=ModuleCategory.AUTH,
|
||||
# 可用性控制
|
||||
env_key="LDAP_AVAILABLE",
|
||||
default_available=True,
|
||||
required_packages=["ldap3"],
|
||||
# 路由配置(使用现有路由,不改变路径)
|
||||
api_prefix="/api/admin/ldap",
|
||||
# 前端配置
|
||||
admin_route="/admin/ldap",
|
||||
admin_menu_icon="Users",
|
||||
admin_menu_group="system",
|
||||
admin_menu_order=50,
|
||||
),
|
||||
router_factory=_get_router,
|
||||
health_check=_health_check,
|
||||
)
|
||||
@@ -130,7 +130,19 @@ class LDAPService:
|
||||
def get_config_data(db: Session) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
提前获取并解密配置,供线程池使用,避免跨线程共享 Session。
|
||||
|
||||
检查顺序:
|
||||
1. LDAP 模块是否激活(available && enabled)
|
||||
2. LDAP 配置是否启用
|
||||
3. 绑定密码是否可解密
|
||||
"""
|
||||
# 检查 LDAP 模块是否激活
|
||||
from src.core.modules import get_module_registry
|
||||
|
||||
registry = get_module_registry()
|
||||
if not registry.is_active("ldap", db):
|
||||
return None
|
||||
|
||||
config = LDAPService.get_config(db)
|
||||
if not config or config.is_enabled is not True:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user