From d43b05bae50f9c0e18f7aa0ca153952c8e7b5664 Mon Sep 17 00:00:00 2001 From: Jerry Date: Mon, 7 Jul 2025 21:48:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules => modules}/bot/bot.controller.ts | 0 {src/modules => modules}/bot/bot.service.ts | 0 .../public/file.controller.ts | 0 .../public/file.service.ts | 0 .../sample/sample.controller.ts | 0 .../sample/sample.service.ts | 0 .../system/system.controller.ts | 0 .../system/system.service.ts | 0 .../test/test.controller.ts | 0 {src/modules => modules}/test/test.service.ts | 0 .../words/words.controller.ts | 0 .../words/words.service.ts | 0 src/core/app.ts | 95 ------- src/core/app/app.ts | 39 +++ src/core/app/loader.ts | 241 ++++++++++++++++++ src/core/types/plugin.ts | 22 ++ src/core/utils/system/path.ts | 3 + src/main.ts | 28 +- 18 files changed, 325 insertions(+), 103 deletions(-) rename {src/modules => modules}/bot/bot.controller.ts (100%) rename {src/modules => modules}/bot/bot.service.ts (100%) rename {src/modules => modules}/public/file.controller.ts (100%) rename {src/modules => modules}/public/file.service.ts (100%) rename {src/modules => modules}/sample/sample.controller.ts (100%) rename {src/modules => modules}/sample/sample.service.ts (100%) rename {src/modules => modules}/system/system.controller.ts (100%) rename {src/modules => modules}/system/system.service.ts (100%) rename {src/modules => modules}/test/test.controller.ts (100%) rename {src/modules => modules}/test/test.service.ts (100%) rename {src/modules => modules}/words/words.controller.ts (100%) rename {src/modules => modules}/words/words.service.ts (100%) delete mode 100644 src/core/app.ts create mode 100644 src/core/app/app.ts create mode 100644 src/core/app/loader.ts create mode 100644 src/core/types/plugin.ts diff --git a/src/modules/bot/bot.controller.ts b/modules/bot/bot.controller.ts similarity index 100% rename from src/modules/bot/bot.controller.ts rename to modules/bot/bot.controller.ts diff --git a/src/modules/bot/bot.service.ts b/modules/bot/bot.service.ts similarity index 100% rename from src/modules/bot/bot.service.ts rename to modules/bot/bot.service.ts diff --git a/src/modules/public/file.controller.ts b/modules/public/file.controller.ts similarity index 100% rename from src/modules/public/file.controller.ts rename to modules/public/file.controller.ts diff --git a/src/modules/public/file.service.ts b/modules/public/file.service.ts similarity index 100% rename from src/modules/public/file.service.ts rename to modules/public/file.service.ts diff --git a/src/modules/sample/sample.controller.ts b/modules/sample/sample.controller.ts similarity index 100% rename from src/modules/sample/sample.controller.ts rename to modules/sample/sample.controller.ts diff --git a/src/modules/sample/sample.service.ts b/modules/sample/sample.service.ts similarity index 100% rename from src/modules/sample/sample.service.ts rename to modules/sample/sample.service.ts diff --git a/src/modules/system/system.controller.ts b/modules/system/system.controller.ts similarity index 100% rename from src/modules/system/system.controller.ts rename to modules/system/system.controller.ts diff --git a/src/modules/system/system.service.ts b/modules/system/system.service.ts similarity index 100% rename from src/modules/system/system.service.ts rename to modules/system/system.service.ts diff --git a/src/modules/test/test.controller.ts b/modules/test/test.controller.ts similarity index 100% rename from src/modules/test/test.controller.ts rename to modules/test/test.controller.ts diff --git a/src/modules/test/test.service.ts b/modules/test/test.service.ts similarity index 100% rename from src/modules/test/test.service.ts rename to modules/test/test.service.ts diff --git a/src/modules/words/words.controller.ts b/modules/words/words.controller.ts similarity index 100% rename from src/modules/words/words.controller.ts rename to modules/words/words.controller.ts diff --git a/src/modules/words/words.service.ts b/modules/words/words.service.ts similarity index 100% rename from src/modules/words/words.service.ts rename to modules/words/words.service.ts diff --git a/src/core/app.ts b/src/core/app.ts deleted file mode 100644 index 1740a41..0000000 --- a/src/core/app.ts +++ /dev/null @@ -1,95 +0,0 @@ -import express from 'express'; -import compression from 'compression'; -import fs from 'fs'; -import path from 'path'; -import logger from './utils/system/logger'; -import paths from './utils/system/path'; -import config from './utils/system/config'; -import './services/ws/wsServer'; -import System from './utils/system/system'; - -const apps = { - async createApp() { - const app = express(); - paths.init(); - logger.info('晶灵核心初始化..'); - - app.use((req, res, next) => { - const contentType = req.headers['content-type'] || ''; - if (contentType.includes('multipart/form-data')) { - logger.debug('检测到form-data数据流,跳过加载 express.json() 中间件..'); - next(); - } else { - express.json()(req, res, next); - } - }); - app.use(compression()); - logger.debug('成功加载 express.json() 中间件..'); - - const publicPath = paths.get('public'); - app.use('/public', express.static(publicPath)); - logger.debug(`静态资源路由挂载: /public => ${publicPath}`); - - const modulesDir = path.resolve(__dirname, './modules'); - const controllerPattern = /\.controller\.[jt]s$/; - - if (!fs.existsSync(modulesDir)) { - logger.warn(`未找到模块目录: ${modulesDir}`); - } else { - const moduleFolders = fs.readdirSync(modulesDir).filter((folder) => { - const fullPath = path.join(modulesDir, folder); - return fs.statSync(fullPath).isDirectory(); - }); - - for (const folder of moduleFolders) { - const folderPath = path.join(modulesDir, folder); - const files = fs.readdirSync(folderPath).filter((f) => controllerPattern.test(f)); - - for (const file of files) { - const filePath = path.join(folderPath, file); - - try { - //logger.debug(`尝试加载模块: ${filePath}`); - const controllerModule = require(filePath); - const controller = controllerModule.default; - - if (controller?.getRouter) { - const isPublic = folder === 'public'; - const routePath = isPublic ? `/${folder}` : `/api/${folder}`; - app.use(routePath, controller.getRouter()); - logger.debug(`模块路由挂载: ${routePath.padEnd(12)} => ${file}`); - - if (config.get('DEBUG', false)) { - controller.getRouter().stack.forEach((layer: any) => { - if (layer.route) { - const methods = Object.keys(layer.route.methods || {}) - .map((m) => m.toUpperCase()) - .join(','); - logger.debug(` ↳ ${methods.padEnd(6)} ${routePath}${layer.route.path}`); - } - }); - } - } else { - logger.warn(`模块 ${file} 没有导出 getRouter 方法,跳过..`); - } - } catch (err) { - logger.error(`模块 ${file} 加载失败:`, err); - } - } - } - } - - const duration = System.checkRestartTime(); - //logger.info(duration); - if (duration) { - logger.warn(`重启完成!耗时 ${duration} 秒..`); - const restartTimePath = path.join(paths.get('temp'), 'restart_time'); - fs.writeFileSync(restartTimePath, duration.toString()); - } - - logger.info('晶灵核心初始化完毕!'); - return app; - }, -}; - -export default apps; diff --git a/src/core/app/app.ts b/src/core/app/app.ts new file mode 100644 index 0000000..9711bc2 --- /dev/null +++ b/src/core/app/app.ts @@ -0,0 +1,39 @@ +import express from 'express'; +import compression from 'compression'; +import logger from '../utils/system/logger'; +import paths from '../utils/system/path'; +import System from '../utils/system/system'; +import path from 'path'; + +const apps = { + async createApp() { + const app = express(); + paths.init(); + logger.info('晶灵核心初始化..'); + app.use((req, res, next) => { + const contentType = req.headers['content-type'] || ''; + if (contentType.includes('multipart/form-data')) { + logger.debug('检测到form-data数据流,跳过加载 express.json() 中间件..'); + next(); + } else { + express.json()(req, res, next); + } + }); + app.use(compression()); + logger.debug('成功加载 express.json() 中间件..'); + const publicPath = paths.get('public'); + app.use('/public', express.static(publicPath)); + logger.debug(`静态资源路由挂载: /public => ${publicPath}`); + const duration = System.checkRestartTime(); + if (duration) { + logger.warn(`重启完成!耗时 ${duration} 秒..`); + const restartTimePath = path.join(paths.get('temp'), 'restart_time'); + require('fs').writeFileSync(restartTimePath, duration.toString()); + } + + logger.info('晶灵核心初始化完毕!'); + return app; + }, +}; + +export default apps; diff --git a/src/core/app/loader.ts b/src/core/app/loader.ts new file mode 100644 index 0000000..039c8f5 --- /dev/null +++ b/src/core/app/loader.ts @@ -0,0 +1,241 @@ +import path from 'path'; +import fs from 'fs/promises'; +import simpleGit, { SimpleGit } from 'simple-git'; +import { Application } from 'express'; +import { Server } from 'http'; +import Plugin from '../types/plugin'; +import logger from '../utils/system/logger'; +import paths from '../utils/system/path'; + +class PluginLoader { + private readonly pluginsDir: string; + private loadedPlugins: Map = new Map(); + private gitInstances: Map = new Map(); + + constructor( + private app: Application, + private server?: Server + ) { + this.pluginsDir = paths.get('plugins'); + } + + public async loadPlugins(): Promise { + try { + const pluginFolders = await fs.readdir(this.pluginsDir); + + await Promise.all( + pluginFolders.map(async (folder) => { + const pluginPath = path.join(this.pluginsDir, folder); + const stat = await fs.stat(pluginPath); + + if (stat.isDirectory()) { + await this.loadPlugin(pluginPath); + } + }) + ); + + await this.invokePluginHooks('onReady'); + } catch (err) { + logger.error('加载插件时出错:', err); + } + } + + private async loadPlugin(pluginPath: string): Promise { + try { + const pluginName = path.basename(pluginPath); + const indexPath = path.join(pluginPath, 'index.js'); + + if (!(await this.fileExists(indexPath))) { + logger.warn(`插件 ${pluginName} 缺少 index.js 入口文件`); + return; + } + + if (await this.checkPluginUpdates(pluginPath)) { + logger.info(`插件 ${pluginName} 有可用更新,正在更新..`); + await this.updatePlugin(pluginPath); + } + + const pluginModule = await import(indexPath); + const plugin: Plugin = pluginModule.default || pluginModule; + + if (!plugin.name || !plugin.version) { + logger.warn(`插件 ${pluginName} 缺少必要信息 (name/version)`); + return; + } + + if (plugin.initialize) { + await plugin.initialize(this.app, this.server); + } + + if (plugin.routes) { + plugin.routes(this.app); + } + + this.loadedPlugins.set(plugin.name, plugin); + logger.info(`插件加载成功: ${plugin.name}@${plugin.version}`); + } catch (err) { + logger.error(`加载插件 ${path.basename(pluginPath)} 失败:`, err); + } + } + + private async checkPluginUpdates(pluginPath: string): Promise { + try { + const git = simpleGit(pluginPath); + this.gitInstances.set(pluginPath, git); + + const isRepo = await git.checkIsRepo(); + if (!isRepo) return false; + + const pluginConfig = await this.getPluginConfig(pluginPath); + if (!pluginConfig.autoUpdateEnabled) return false; + + await git.fetch(); + const status = await git.status(); + + return status.behind > 0; + } catch (err) { + logger.warn(`检查插件更新失败: ${path.basename(pluginPath)}`, err); + return false; + } + } + + private async updatePlugin(pluginPath: string): Promise { + try { + const git = this.gitInstances.get(pluginPath); + if (!git) return; + + const status = await git.status(); + if (status.current) { + await git.pull('origin', status.current); + logger.info(`插件 ${path.basename(pluginPath)} 更新成功`); + + await this.loadPlugin(pluginPath); + } + } catch (err) { + logger.error(`更新插件 ${path.basename(pluginPath)} 失败:`, err); + } + } + + public async closePlugins(): Promise { + await this.invokePluginHooks('onClose'); + this.loadedPlugins.clear(); + this.gitInstances.clear(); + } + + private async invokePluginHooks(hookName: 'onReady' | 'onClose'): Promise { + for (const [name, plugin] of this.loadedPlugins) { + try { + if (plugin[hookName]) { + await plugin[hookName]!(); + } + } catch (err) { + logger.error(`执行插件 ${name} 的 ${hookName} 钩子失败:`, err); + } + } + } + + /** + * 检查目录是否存在 + * @param path 目录路径 + */ + private async dirExists(path: string): Promise { + try { + const stat = await fs.stat(path); + return stat.isDirectory(); + } catch (err) { + logger.error(err); + } + } + + /** + * 检查文件是否存在 + * @param path 文件路径 + */ + private async fileExists(path: string): Promise { + try { + const stat = await fs.stat(path); + return stat.isFile(); + } catch (err) { + logger.error(err); + } + } + + /** + * 获取插件配置 + * @param pluginPath 插件路径 + */ + private async getPluginConfig(pluginPath: string): Promise<{ + autoUpdateEnabled: boolean; + [key: string]: any; + }> { + const packagePath = path.join(pluginPath, 'package.json'); + + try { + if (!(await this.fileExists(packagePath))) { + return { autoUpdateEnabled: false }; + } + + const pkg = JSON.parse(await fs.readFile(packagePath, 'utf-8')); + return { + autoUpdateEnabled: pkg?.crystelf?.autoUpdate ?? false, + ...pkg, + }; + } catch (err) { + logger.warn(`读取插件配置失败: ${path.basename(pluginPath)}`, err); + return { autoUpdateEnabled: false }; + } + } + + /** + * 获取插件元数据 + * @param pluginPath 插件路径 + */ + private async getPluginMetadata(pluginPath: string): Promise<{ + name: string; + version: string; + description?: string; + } | null> { + const packagePath = path.join(pluginPath, 'package.json'); + + try { + if (!(await this.fileExists(packagePath))) { + return null; + } + + const pkg = JSON.parse(await fs.readFile(packagePath, 'utf-8')); + return { + name: pkg.name, + version: pkg.version, + description: pkg.description, + }; + } catch (err) { + logger.warn(`读取插件元数据失败: ${path.basename(pluginPath)}`, err); + return null; + } + } + + /** + * 验证插件名称是否符合规范 + * @param name 插件名称 + */ + private isValidPluginName(name: string): boolean { + return /^[a-z][a-z0-9-]*$/.test(name); + } + + /** + * 获取所有已加载插件信息 + */ + public getLoadedPlugins(): Array<{ + name: string; + version: string; + path: string; + }> { + return Array.from(this.loadedPlugins.entries()).map(([name, plugin]) => ({ + name, + version: plugin.version, + path: path.join(this.pluginsDir, name), + })); + } +} + +export default PluginLoader; diff --git a/src/core/types/plugin.ts b/src/core/types/plugin.ts new file mode 100644 index 0000000..0c1221b --- /dev/null +++ b/src/core/types/plugin.ts @@ -0,0 +1,22 @@ +import { Application } from 'express'; +import { Server } from 'http'; + +interface Plugin { + name: string; + version: string; + description?: string; + // 初始化插件 + initialize?: (app: Application, server?: Server) => void | Promise; + // 路由挂载点 + routes?: (app: Application) => void; + // 生命周期钩子 + onReady?: () => void | Promise; + onClose?: () => void | Promise; + onError?: (error: Error) => void; + // 自动更新相关 + autoUpdateEnabled?: boolean; + checkForUpdates?: () => Promise; + applyUpdate?: () => Promise; +} + +export default Plugin; diff --git a/src/core/utils/system/path.ts b/src/core/utils/system/path.ts index c3b683f..80d5b85 100644 --- a/src/core/utils/system/path.ts +++ b/src/core/utils/system/path.ts @@ -39,6 +39,7 @@ class PathManager { modules: path.join(this.baseDir, 'src/modules'), uploads: path.join(this.baseDir, 'public/files/uploads'), words: path.join(this.baseDir, 'private/data/word'), + plugins: path.join(this.baseDir, 'plugins'), }; return type ? mappings[type] : this.baseDir; @@ -69,6 +70,7 @@ class PathManager { this.get('temp'), this.get('uploads'), this.get('words'), + this.get('plugins'), ]; pathsToInit.forEach((dirPath) => { @@ -92,6 +94,7 @@ type PathType = | 'media' | 'modules' | 'words' + | 'plugins' | 'uploads'; const paths = PathManager.getInstance(); diff --git a/src/main.ts b/src/main.ts index 532a61c..7c51499 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ -import apps from './core/app'; +import apps from './core/app/app'; import logger from './core/utils/system/logger'; import config from './core/utils/system/config'; -import redis from './core/services/redis/redis'; import autoUpdater from './core/utils/system/autoUpdater'; import System from './core/utils/system/system'; +import PluginLoader from './core/app/loader'; config.check(['PORT', 'DEBUG', 'RD_PORT', 'RD_ADD', 'WS_SECRET', 'WS_PORT']); const PORT = config.get('PORT') || 3000; @@ -11,21 +11,33 @@ const PORT = config.get('PORT') || 3000; apps .createApp() .then(async (app) => { - app.listen(PORT, () => { + const server = app.listen(PORT, () => { logger.info(`Crystelf-core listening on ${PORT}`); }); + + const pluginLoader = new PluginLoader(app, server); + await pluginLoader.loadPlugins(); const isUpdated = await autoUpdater.checkForUpdates(); if (isUpdated) { logger.warn(`检测到更新,正在重启..`); await System.restart(); } + + process.on('SIGTERM', async () => { + logger.info('收到终止信号,正在关闭插件和服务..'); + await pluginLoader.closePlugins(); + process.exit(0); + }); + + process.on('uncaughtException', (err) => { + logger.error('未捕获的异常:', err); + }); + + process.on('unhandledRejection', (reason, promise) => { + logger.error('未处理的Promise拒绝:', reason); + }); }) .catch((err) => { logger.error('Crystelf-core启动失败:', err); process.exit(1); }); - -process.on('SIGTERM', async () => { - await redis.disconnect(); - process.exit(0); -});