feat: 实现功能模块系统,支持模块的动态注册与启用控制

- 新增 ModuleRegistry 核心,支持模块注册、可用性检查和启用状态管理
- 新增模块管理 API(管理端和公共端),提供模块状态查询和启用控制
- 将 LDAP 功能迁移到模块系统,支持通过环境变量和数据库配置控制
- 前端新增模块管理页面和 store,支持模块启用/禁用操作
- 路由守卫集成模块状态检查,未激活模块的页面自动重定向
- 面包屑导航支持模块配置页面的层级显示
- Switch 组件新增 disabled 属性支持
This commit is contained in:
fawney19
2026-01-16 16:14:12 +08:00
parent 50343d5459
commit 6c98816f9f
17 changed files with 1332 additions and 39 deletions
+63
View 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 -1
View File
@@ -3,7 +3,8 @@
type="button" type="button"
role="switch" role="switch"
:aria-checked="modelValue" :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="[ :class="[
modelValue ? 'bg-primary' : 'bg-muted' modelValue ? 'bg-primary' : 'bg-muted'
]" ]"
@@ -21,6 +22,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
modelValue: boolean modelValue: boolean
disabled?: boolean
}>() }>()
defineEmits<{ defineEmits<{
+77 -35
View File
@@ -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"> <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 flex-col gap-0.5">
<div class="flex items-center gap-2 text-sm text-muted-foreground"> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{{ currentSectionName }}</span> <template v-for="(crumb, index) in breadcrumbs" :key="index">
<ChevronRight class="w-3 h-3 opacity-50" /> <template v-if="index > 0">
<span class="text-foreground font-medium">{{ currentPageName }}</span> <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>
</div> </div>
@@ -313,6 +328,7 @@
import { computed, ref, watch, onMounted, onUnmounted } from 'vue' import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode' import { useDarkMode } from '@/composables/useDarkMode'
import { isDemoMode } from '@/config/demo' import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin' import { adminApi, type CheckUpdateResponse } from '@/api/admin'
@@ -345,12 +361,14 @@ import {
Menu, Menu,
X, X,
Mail, Mail,
Puzzle,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import GithubIcon from '@/components/icons/GithubIcon.vue' import GithubIcon from '@/components/icons/GithubIcon.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode() const { themeMode, toggleDarkMode } = useDarkMode()
const isDemo = computed(() => isDemoMode()) const isDemo = computed(() => isDemoMode())
@@ -415,6 +433,11 @@ onMounted(() => {
} }
}, 5000) }, 5000)
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) {
moduleStore.fetchModules()
}
// 延迟检查更新,避免影响页面加载 // 延迟检查更新,避免影响页面加载
setTimeout(() => { setTimeout(() => {
checkForUpdate() 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 = [ const adminNavigation = [
{ {
title: '概览', title: '概览',
@@ -494,46 +530,52 @@ const navigation = computed(() => {
}, },
{ {
title: '系统', title: '系统',
items: [ items: 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 },
{ name: 'LDAP 配置', href: '/admin/ldap', icon: Shield },
{ name: '系统设置', href: '/admin/system', icon: Cog },
]
} }
] ]
return authStore.user?.role === 'admin' ? adminNavigation : baseNavigation return authStore.user?.role === 'admin' ? adminNavigation : baseNavigation
}) })
// Dynamic Header Title // Breadcrumbs
const currentSectionName = computed(() => { interface BreadcrumbItem {
// Special case: personal settings page accessed by admin label: string
if (route.path === '/dashboard/settings') { href?: string
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 ''
})
const currentPageName = computed(() => { const breadcrumbs = computed((): BreadcrumbItem[] => {
// Special case: personal settings page accessed by admin // Special case: personal settings page accessed by admin
if (route.path === '/dashboard/settings') { if (route.path === '/dashboard/settings') {
return '个人设置' 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 [{ label: '仪表盘' }]
return active ? active.name : route.name?.toString() || '仪表盘'
}) })
// Styling Classes (Editorial) // Styling Classes (Editorial)
+30 -1
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { importWithRetry } from '@/utils/importRetry' import { importWithRetry } from '@/utils/importRetry'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
@@ -116,6 +117,11 @@ const routes: RouteRecordRaw[] = [
name: 'SystemSettings', name: 'SystemSettings',
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue')) component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
}, },
{
path: 'modules',
name: 'ModuleManagement',
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
},
{ {
path: 'email', path: 'email',
name: 'EmailSettings', name: 'EmailSettings',
@@ -124,7 +130,8 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'ldap', path: 'ldap',
name: 'LdapSettings', name: 'LdapSettings',
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue')) component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue')),
meta: { module: 'ldap' }
}, },
{ {
path: 'audit-logs', path: 'audit-logs',
@@ -164,6 +171,7 @@ function isNetworkError(error: any): boolean {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const moduleStore = useModuleStore()
try { try {
// 如果有token但没有用户信息,尝试获取用户信息 // 如果有token但没有用户信息,尝试获取用户信息
@@ -186,6 +194,7 @@ router.beforeEach(async (to, from, next) => {
// 检查整个路由匹配记录链中的 meta // 检查整个路由匹配记录链中的 meta
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false) const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin) const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as string | undefined
// 如果需要认证但没有token,跳转到首页 // 如果需要认证但没有token,跳转到首页
if (requiresAuth && !authStore.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') log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
next('/dashboard') next('/dashboard')
} else { } 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() next()
} }
} else { } else {
+109
View 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,
}
})
@@ -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 -2
View File
@@ -5,8 +5,8 @@ from fastapi import APIRouter
from .adaptive import router as adaptive_router from .adaptive import router as adaptive_router
from .api_keys import router as api_keys_router from .api_keys import router as api_keys_router
from .endpoints import router as endpoints_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 .management_tokens import router as management_tokens_router
from .modules import router as modules_router
from .models import router as models_router from .models import router as models_router
from .monitoring import router as monitoring_router from .monitoring import router as monitoring_router
from .provider_query import router as provider_query_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(models_router)
router.include_router(security_router) router.include_router(security_router)
router.include_router(provider_query_router) router.include_router(provider_query_router)
router.include_router(ldap_router)
router.include_router(management_tokens_router) router.include_router(management_tokens_router)
router.include_router(modules_router)
# 注意:ldap_router 已迁移到模块系统,由 ModuleRegistry 动态注册
# 当 LDAP_AVAILABLE=true 时才会注册路由
__all__ = ["router"] __all__ = ["router"]
+189
View 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()
+2
View File
@@ -7,6 +7,7 @@ from .catalog import router as catalog_router
from .claude import router as claude_router from .claude import router as claude_router
from .gemini import router as gemini_router from .gemini import router as gemini_router
from .models import router as models_router from .models import router as models_router
from .modules import router as modules_router
from .openai import router as openai_router from .openai import router as openai_router
from .system_catalog import router as system_catalog_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(system_catalog_router, tags=["System Catalog"])
router.include_router(catalog_router) router.include_router(catalog_router)
router.include_router(capabilities_router) router.include_router(capabilities_router)
router.include_router(modules_router)
__all__ = ["router"] __all__ = ["router"]
+46
View 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
View 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
View 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
View 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
View File
@@ -24,6 +24,7 @@ from src.clients.http_client import HTTPClientPool, close_http_clients
from src.config import config from src.config import config
from src.core.exceptions import ExceptionHandlers, ProxyException from src.core.exceptions import ExceptionHandlers, ProxyException
from src.core.logger import logger from src.core.logger import logger
from src.core.modules import get_module_registry
from src.database import init_db from src.database import init_db
from src.middleware.plugin_middleware import PluginMiddleware from src.middleware.plugin_middleware import PluginMiddleware
@@ -159,6 +160,30 @@ async def lifespan(app: FastAPI):
register_all_converters() 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(f"服务启动成功: http://{config.host}:{config.port}")
logger.info("=" * 60) logger.info("=" * 60)
@@ -250,6 +275,12 @@ async def lifespan(app: FastAPI):
logger.info("关闭插件系统...") logger.info("关闭插件系统...")
await plugin_manager.shutdown_all() await plugin_manager.shutdown_all()
# 关闭功能模块
logger.info("关闭功能模块...")
for module in available_modules:
if module.on_shutdown:
await module.on_shutdown()
# 关闭并发管理器 # 关闭并发管理器
logger.info("关闭并发管理器...") logger.info("关闭并发管理器...")
if concurrency_manager: if concurrency_manager:
+19
View 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
View 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,
)
+12
View File
@@ -130,7 +130,19 @@ class LDAPService:
def get_config_data(db: Session) -> Optional[Dict[str, Any]]: def get_config_data(db: Session) -> Optional[Dict[str, Any]]:
""" """
提前获取并解密配置,供线程池使用,避免跨线程共享 Session。 提前获取并解密配置,供线程池使用,避免跨线程共享 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) config = LDAPService.get_config(db)
if not config or config.is_enabled is not True: if not config or config.is_enabled is not True:
return None return None