nest初始化

feat:配置模块
feat:路径模块
This commit is contained in:
Jerry 2025-07-24 14:23:28 +08:00
parent 4846e27e6a
commit 4100b4f0aa
67 changed files with 6518 additions and 3809 deletions

59
.gitignore vendored
View File

@ -1,15 +1,56 @@
/tmp # compiled output
/out-tsc /dist
/node_modules /node_modules
/build
# Logs
logs
*.log
npm-debug.log* npm-debug.log*
pnpm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
/.pnp lerna-debug.log*
.pnp.js
.env # OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/* .vscode/*
/dist/ !.vscode/settings.json
/logs/ !.vscode/tasks.json
/private/ !.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

8
.idea/.gitignore generated vendored
View File

@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -26,7 +26,7 @@
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" /> <option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings> </VueCodeStyleSettings>
<codeStyleSettings language="HTML"> <codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" /> <option name="SOFT_MARGINS" value="80" />
<indentOptions> <indentOptions>
<option name="INDENT_SIZE" value="2" /> <option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" /> <option name="CONTINUATION_INDENT_SIZE" value="2" />
@ -34,7 +34,7 @@
</indentOptions> </indentOptions>
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="JavaScript"> <codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" /> <option name="SOFT_MARGINS" value="80" />
<indentOptions> <indentOptions>
<option name="INDENT_SIZE" value="2" /> <option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" /> <option name="CONTINUATION_INDENT_SIZE" value="2" />
@ -42,7 +42,7 @@
</indentOptions> </indentOptions>
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="TypeScript"> <codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" /> <option name="SOFT_MARGINS" value="80" />
<indentOptions> <indentOptions>
<option name="INDENT_SIZE" value="2" /> <option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" /> <option name="CONTINUATION_INDENT_SIZE" value="2" />
@ -50,7 +50,7 @@
</indentOptions> </indentOptions>
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="Vue"> <codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" /> <option name="SOFT_MARGINS" value="80" />
<indentOptions> <indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" /> <option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions> </indentOptions>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/logs" />
<excludeFolder url="file://$MODULE_DIR$/private" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/crystelf-core.iml" filepath="$PROJECT_DIR$/.idea/crystelf-core.iml" />
</modules>
</component>
</project>

3
.idea/prettier.xml generated
View File

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="PrettierConfiguration"> <component name="PrettierConfiguration">
<option name="myConfigurationMode" value="MANUAL" /> <option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" /> <option name="myRunOnSave" value="true" />
<option name="myRunOnReformat" value="true" />
</component> </component>
</project> </project>

2
.idea/vcs.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

14
.idea/webResources.xml generated
View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebResourcesPaths">
<contentEntries>
<entry url="file://$PROJECT_DIR$">
<entryData>
<resourceRoots>
<path value="file://$PROJECT_DIR$/src" />
</resourceRoots>
</entryData>
</entry>
</contentEntries>
</component>
</project>

View File

@ -1,7 +1,4 @@
{ {
"semi": true,
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "trailingComma": "all"
"tabWidth": 2,
"trailingComma": "es5"
} }

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Crystelf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

34
eslint.config.mjs Normal file
View File

@ -0,0 +1,34 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -1,33 +1,73 @@
{ {
"name": "crystelf-core", "name": "nest-backend",
"version": "1.0.0", "version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": { "scripts": {
"dev": "ts-node-dev src/main.ts", "build": "nest build",
"start": "node dist/main.js", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"build": "tsc" "start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
}, },
"dependencies": { "dependencies": {
"axios": "^1.8.4", "@nestjs/axios": "^4.0.1",
"chalk": "4", "@nestjs/common": "^11.0.1",
"compression": "^1.8.0", "@nestjs/config": "^4.0.2",
"dotenv": "^16.0.0", "@nestjs/core": "^11.0.1",
"express": "^4.18.0", "@nestjs/platform-express": "^11.0.1",
"ioredis": "^5.6.0", "@nestjs/swagger": "^11.2.0",
"mkdirp": "^3.0.1", "axios": "^1.10.0",
"multer": "1.4.5-lts.2", "reflect-metadata": "^0.2.2",
"simple-git": "^3.27.0", "rxjs": "^7.8.1",
"uuid": "^11.1.0", "ssh2": "^1.16.0"
"ws": "^8.18.1"
}, },
"devDependencies": { "devDependencies": {
"@types/compression": "^1.7.5", "@eslint/eslintrc": "^3.2.0",
"@types/express": "^4.17.0", "@eslint/js": "^9.18.0",
"@types/mkdirp": "^2.0.0", "@nestjs/cli": "^11.0.0",
"@types/multer": "^1.4.12", "@nestjs/schematics": "^11.0.0",
"@types/node": "^18.0.0", "@nestjs/testing": "^11.0.1",
"@types/ws": "^8.18.1", "@swc/cli": "^0.6.0",
"prettier": "^3.5.3", "@swc/core": "^1.10.7",
"ts-node-dev": "^2.0.0", "@types/express": "^5.0.0",
"typescript": "^5.0.0" "@types/jest": "^29.5.14",
"@types/node": "^22.16.4",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
} }
} }

7239
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

9
src/app.module.ts Normal file
View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RootModule } from './root/root.module';
import { AppConfigModule } from './config/config.module';
import { PathModule } from './core/path/path.module';
@Module({
imports: [RootModule, AppConfigModule, PathModule],
})
export class AppModule {}

View File

@ -1,95 +0,0 @@
import express from 'express';
import compression from 'compression';
import fs from 'fs';
import path from 'path';
import logger from './utils/core/logger';
import paths from './utils/core/path';
import config from './utils/core/config';
import './services/ws/wsServer';
import System from './utils/core/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;

View File

@ -0,0 +1,28 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException ? exception.message : '服务器内部错误';
response.status(status).json({
success: false,
data: null,
message,
});
}
}

View File

@ -0,0 +1,26 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { ApiResponse } from '../response-format';
@Injectable()
export class ResponseInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
message: '操作成功',
})),
);
}
}

View File

@ -0,0 +1,5 @@
export interface ApiResponse<T = any> {
success: boolean;
data: T;
message: string;
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import { AppConfigService } from './config.service';
import * as path from 'node:path';
@Module({
imports: [
NestConfigModule.forRoot({
envFilePath: path.resolve(__dirname, '../../.env'),
isGlobal: true,
}),
],
providers: [AppConfigService],
exports: [AppConfigService],
})
export class AppConfigModule {}

View File

@ -0,0 +1,47 @@
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService as NestConfigService } from '@nestjs/config';
@Injectable()
export class AppConfigService implements OnModuleInit {
private readonly logger = new Logger(AppConfigService.name);
constructor(
@Inject(NestConfigService)
private readonly nestConfigService: NestConfigService,
) {}
onModuleInit() {
this.checkRequiredVariables();
}
get<T = string>(key: string, defaultValue?: T): T | undefined {
const value = this.nestConfigService.get<T>(key);
if (value === undefined || value === null) {
if (defaultValue !== undefined) {
return defaultValue;
}
this.logger.error(`环境变量 ${key} 未定义!`);
}
return value;
}
private checkRequiredVariables(): void {
this.logger.log('检查必要环境变量..');
const requiredVariables = [
'PORT',
'RD_PORT',
'RD_ADD',
'WS_SECRET',
'WS_PORT',
];
requiredVariables.forEach((key) => {
const value = this.nestConfigService.get(key);
if (value === undefined || value === null) {
this.logger.fatal(`必需环境变量缺失: ${key}`);
} else {
this.logger.debug(`检测到环境变量: ${key}`);
}
});
}
}

View File

@ -1 +0,0 @@
keep

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PathService } from './path.service';
@Module({
providers: [PathService],
exports: [PathService],
})
export class PathModule {}

View File

@ -0,0 +1,119 @@
import { Injectable } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs';
import { ConfigService } from '../../config/config.service';
import { Logger } from '@nestjs/common';
@Injectable()
export class PathService {
private readonly baseDir: string;
private readonly logger = new Logger(PathService.name);
constructor(private readonly configService: ConfigService) {
this.baseDir = path.join(__dirname, '../../..');
this.initializePaths();
}
/**
*
* @param type
*/
get(type?: PathType): string {
const mappings: Record<PathType, string> = {
root: this.baseDir,
public: path.join(this.baseDir, 'public'),
images: path.join(this.baseDir, 'public/files/image'),
log: path.join(this.baseDir, 'logs'),
config: path.join(this.baseDir, 'config'),
temp: path.join(this.baseDir, 'temp'),
userData: path.join(this.baseDir, 'private/data'),
files: path.join(this.baseDir, 'public/files'),
media: path.join(this.baseDir, 'public/files/media'),
package: path.join(this.baseDir, 'package.json'),
modules: path.join(this.baseDir, 'src/modules'),
uploads: path.join(this.baseDir, 'public/files/uploads'),
words: path.join(this.baseDir, 'private/data/word'),
};
return type ? mappings[type] : this.baseDir;
}
/**
*
*/
private initializePaths(): void {
this.logger.log('path初始化..');
const pathsToInit = [
this.get('log'),
this.get('config'),
this.get('userData'),
this.get('media'),
this.get('temp'),
this.get('uploads'),
this.get('words'),
];
pathsToInit.forEach((dirPath) => {
if (!fs.existsSync(dirPath)) {
this.createDir(dirPath);
this.logger.debug(`创建目录:${dirPath}..`);
}
});
this.logger.log('path初始化完毕!');
}
/**
*
* @param targetPath
* @param includeFile
*/
createDir(targetPath: string, includeFile: boolean = false): void {
try {
const dirToCreate = includeFile ? path.dirname(targetPath) : targetPath;
fs.mkdirSync(dirToCreate, { recursive: true });
this.logger.debug(`成功创建目录: ${dirToCreate}`);
} catch (err) {
this.logger.error(`创建目录失败: ${err}`);
throw err;
}
}
/**
*
* @param paths
*/
join(...paths: string[]): string {
return path.join(...paths);
}
/**
*
* @param filePath
*/
getExtension(filePath: string): string {
return path.extname(filePath);
}
/**
*
* @param filePath
*/
getBasename(filePath: string): string {
return path.basename(filePath, path.extname(filePath));
}
}
export type PathType =
| 'root'
| 'public'
| 'images'
| 'log'
| 'config'
| 'temp'
| 'userData'
| 'files'
| 'package'
| 'media'
| 'modules'
| 'words'
| 'uploads';

View File

@ -1,31 +1,26 @@
import apps from './app'; import { NestFactory } from '@nestjs/core';
import logger from './utils/core/logger'; import { AppModule } from './app.module';
import config from './utils/core/config'; import { Logger } from '@nestjs/common';
import redis from './services/redis/redis'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import autoUpdater from './utils/core/autoUpdater'; import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import System from './utils/core/system'; import { AllExceptionsFilter } from './common/filters/all-exception.filter';
config.check(['PORT', 'DEBUG', 'RD_PORT', 'RD_ADD', 'WS_SECRET', 'WS_PORT']); async function bootstrap() {
const PORT = config.get('PORT') || 3000; Logger.log('晶灵核心初始化..');
const app = await NestFactory.create(AppModule);
apps app.setGlobalPrefix('api');
.createApp() app.useGlobalInterceptors(new ResponseInterceptor());
.then(async (app) => { app.useGlobalFilters(new AllExceptionsFilter());
app.listen(PORT, () => { const config = new DocumentBuilder()
logger.info(`Crystelf-core listening on ${PORT}`); .setTitle('晶灵核心')
}); .setDescription('为晶灵提供API服务')
const isUpdated = await autoUpdater.checkForUpdates(); .setVersion('1.0')
if (isUpdated) { .build();
logger.warn(`检测到更新,正在重启..`); const document = () => SwaggerModule.createDocument(app, config);
await System.restart(); SwaggerModule.setup('', app, document);
} await app.listen(7000);
}) }
.catch((err) => { bootstrap().then(() => {
logger.error('Crystelf-core启动失败:', err); Logger.log(`API服务已启动http://localhost:7000`);
process.exit(1); Logger.log(`API文档 http://localhost:7000/api`);
});
process.on('SIGTERM', async () => {
await redis.disconnect();
process.exit(0);
}); });

View File

@ -1,156 +0,0 @@
import express from 'express';
import response from '../../utils/core/response';
import BotService from './bot.service';
import tools from '../../utils/modules/tools';
import logger from '../../utils/core/logger';
import wsClientManager from '../../services/ws/wsClientManager';
class BotController {
private readonly router: express.Router;
constructor() {
this.router = express.Router();
this.init();
}
public getRouter(): express.Router {
return this.router;
}
private init(): void {
this.router.post(`/getBotId`, this.postBotsId);
this.router.post('/getGroupInfo', this.postGroupInfo);
this.router.post('/sendMessage', this.sendMessage);
this.router.post('/reportBots', this.reportBots);
this.router.post('/broadcast', this.smartBroadcast);
}
/**
* botId数组
* @param req
* @param res
*/
private postBotsId = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const result = await BotService.getBotId();
await response.success(res, result);
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (err) {
await response.error(res, `请求失败..`, 500, err);
}
};
/**
*
* @example req示例
* ```json
* {
* token: 114514,
* groupId: 114514
* }
* ```
* @param req
* @param res
*/
private postGroupInfo = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const groupId: number = req.body.groupId;
let returnData = await BotService.getGroupInfo({ groupId: groupId });
if (returnData) {
await response.success(res, returnData);
logger.debug(returnData);
} else {
await response.error(res);
}
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
/**
* 广bot连接情况
* @param req
* @param res
*/
// TODO 测试接口可用性
private reportBots = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const sendMessage = {
type: 'reportBots',
data: {},
};
logger.info(`正在请求同步bot数据..`);
await response.success(res, {});
await wsClientManager.broadcast(sendMessage);
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
/**
* ,client
* @param req
* @param res
*/
// TODO 测试接口可用性
private sendMessage = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const groupId: number = Number(req.body.groupId);
const message: string = req.body.message.toString();
const flag: boolean = await BotService.sendMessage(groupId, message);
if (flag) {
await response.success(res, { message: '消息发送成功..' });
} else {
await response.error(res);
}
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
/**
* 广
* @param req
* @param res
*/
// TODO 测试接口可用性
private smartBroadcast = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
const message = req.body.message;
if (!message || typeof message !== 'string') {
return await response.error(res, '缺少 message 字段', 400);
}
if (tools.checkToken(token.toString())) {
logger.info(`广播任务已开始,正在后台执行..`);
await response.success(res, '广播任务已开始,正在后台执行..');
await BotService.broadcastToAllGroups(message);
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
}
export default new BotController();

View File

@ -1,241 +0,0 @@
import logger from '../../utils/core/logger';
import paths from '../../utils/core/path';
import fs from 'fs/promises';
import path from 'path';
import redisService from '../../services/redis/redis';
import wsClientManager from '../../services/ws/wsClientManager';
import tools from '../../utils/core/tool';
class BotService {
/**
* botId数组
*/
public async getBotId(): Promise<{ uin: number; nickName: string }[]> {
logger.debug('GetBotId..');
const userPath = paths.get('userData');
const botsPath = path.join(userPath, '/crystelfBots');
const dirData = await fs.readdir(botsPath);
const uins: { uin: number; nickName: string }[] = [];
for (const fileName of dirData) {
if (!fileName.endsWith('.json')) continue;
try {
const raw = await redisService.fetch('crystelfBots', fileName);
if (!raw || !Array.isArray(raw)) continue;
for (const bot of raw) {
const uin = Number(bot.uin);
const nickName = bot.nickName || '';
if (!isNaN(uin)) {
uins.push({ uin, nickName });
}
}
} catch (err) {
logger.error(`读取或解析 ${fileName} 出错: ${err}`);
}
}
logger.debug(uins);
return uins;
}
/**
*
* @param data
*/
public async getGroupInfo(data: {
botId?: number;
groupId: number;
clientId?: string;
}): Promise<any> {
logger.debug('GetGroupInfo..');
const sendBot: number | undefined = data.botId ?? (await this.getGroupBot(data.groupId));
if (!sendBot) {
logger.warn(`不存在能向群聊${data.groupId}发送消息的Bot!`);
return undefined;
}
const sendData = {
type: 'getGroupInfo',
data: {
botId: sendBot,
groupId: data.groupId,
clientID: data.clientId ?? (await this.getBotClient(sendBot)),
},
};
if (sendData.data.clientID) {
const returnData = await wsClientManager.sendAndWait(sendData.data.clientID, sendData);
return returnData ?? undefined;
}
return undefined;
}
/**
*
* @param groupId
* @param message
*/
public async sendMessage(groupId: number, message: string): Promise<boolean> {
logger.info(`发送${message}${groupId}..`);
const sendBot = await this.getGroupBot(groupId);
if (!sendBot) {
logger.warn(`不存在能向群聊${groupId}发送消息的Bot!`);
return false;
}
const client = await this.getBotClient(sendBot);
if (!client) {
logger.warn(`不存在${sendBot}对应的client!`);
return false;
}
const sendData = {
type: 'sendMessage',
data: {
botId: sendBot,
groupId: groupId,
clientId: client,
message: message,
},
};
await wsClientManager.send(client, sendData);
return true;
}
/**
* 广
* @param message 广
*/
// TODO 添加群聊信誉分机制低于30分的群聊不播报等..
public async broadcastToAllGroups(message: string): Promise<void> {
const userPath = paths.get('userData');
const botsPath = path.join(userPath, '/crystelfBots');
const dirData = await fs.readdir(botsPath);
const groupMap: Map<number, { botId: number; clientId: string }[]> = new Map();
for (const fileName of dirData) {
if (!fileName.endsWith('.json')) continue;
const clientId = path.basename(fileName, '.json');
const botList = await redisService.fetch('crystelfBots', fileName);
if (!Array.isArray(botList)) continue;
for (const bot of botList) {
const botId = Number(bot.uin);
const groups = bot.groups;
if (!botId || !Array.isArray(groups)) continue;
for (const group of groups) {
if (group.group_id === '未知') continue;
const groupId = Number(group.group_id);
if (isNaN(groupId)) continue;
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
}
groupMap.get(groupId)!.push({ botId, clientId });
}
}
}
for (const [groupId, botEntries] of groupMap.entries()) {
logger.debug(`[群 ${groupId}] 候选Bot列表: ${JSON.stringify(botEntries)}`);
const clientGroups = new Map<string, number[]>();
botEntries.forEach(({ botId, clientId }) => {
if (!clientGroups.has(clientId)) clientGroups.set(clientId, []);
clientGroups.get(clientId)!.push(botId);
});
const selectedClientId = tools.getRandomItem([...clientGroups.keys()]);
const botCandidates = clientGroups.get(selectedClientId)!;
const selectedBotId = tools.getRandomItem(botCandidates);
const delay = tools.getRandomDelay(10_000, 150_000);
((groupId, selectedClientId, selectedBotId, delay) => {
setTimeout(() => {
const sendData = {
type: 'sendMessage',
data: {
botId: selectedBotId,
groupId: groupId,
clientId: selectedClientId,
message: message,
},
};
logger.info(
`[广播] 向群 ${groupId} 使用Bot ${selectedBotId}(客户端 ${selectedClientId})发送消息${message},延迟 ${delay / 1000}`
);
wsClientManager.send(selectedClientId, sendData).catch((e) => {
logger.error(`发送到群${groupId}失败:`, e);
});
}, delay);
})(groupId, selectedClientId, selectedBotId, delay);
}
}
/**
* botId对应的client
* @param botId
* @private
*/
private async getBotClient(botId: number): Promise<string | undefined> {
const userPath = paths.get('userData');
const botsPath = path.join(userPath, '/crystelfBots');
const dirData = await fs.readdir(botsPath);
for (const clientId of dirData) {
if (!clientId.endsWith('.json')) continue;
try {
const raw = await redisService.fetch('crystelfBots', clientId);
if (!Array.isArray(raw)) continue;
for (const bot of raw) {
const uin = Number(bot.uin);
if (!isNaN(uin) && uin === botId) {
return path.basename(clientId, '.json');
}
}
} catch (err) {
logger.error(`读取${clientId}出错..`);
}
}
return undefined;
}
/**
* groupId对应的botId
* @param groupId
* @private
*/
private async getGroupBot(groupId: number): Promise<number | undefined> {
const userPath = paths.get('userData');
const botsPath = path.join(userPath, '/crystelfBots');
const dirData = await fs.readdir(botsPath);
for (const clientId of dirData) {
if (!clientId.endsWith('.json')) continue;
try {
const raw = await redisService.fetch('crystelfBots', clientId);
if (!Array.isArray(raw)) continue;
for (const bot of raw) {
const uin = Number(bot.uin);
const groups = bot.groups;
if (!uin || !Array.isArray(groups)) continue;
if (groups.find((g) => Number(g.group_id) === groupId)) {
return uin;
}
}
} catch (err) {
logger.error(`读取${clientId}出错..`);
}
}
return undefined;
}
}
export default new BotService();

View File

@ -1,112 +0,0 @@
import express from 'express';
import FileService from './file.service';
import logger from '../../utils/core/logger';
import response from '../../utils/core/response';
import paths from '../../utils/core/path';
import multer from 'multer';
import tools from '../../utils/modules/tools';
const uploadDir = paths.get('uploads');
const upload = multer({
dest: uploadDir,
});
class FileController {
private readonly router: express.Router;
private readonly FileService: FileService;
constructor() {
this.router = express.Router();
this.FileService = new FileService();
this.initializeRoutes();
}
public getRouter(): express.Router {
return this.router;
}
private initializeRoutes(): void {
this.router.get('*', this.handleGetFile);
this.router.post('/upload', upload.single('file'), this.handleUploadFile);
}
/**
* get文件
* @param req
* @param res
*/
private handleGetFile = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const fullPath = req.params[0];
logger.debug(`有个小可爱正在请求${fullPath}噢..`);
const filePath = await this.FileService.getFile(fullPath);
if (!filePath) {
logger.warn(`${fullPath}:文件不存在..`);
await response.error(res, '文件不存在啦!', 404);
return;
}
res.sendFile(filePath);
logger.info(`成功投递文件: ${filePath}`);
} catch (error) {
await response.error(res, '晶灵服务处理文件请求时出错..', 500);
logger.error('晶灵数据请求处理失败:', error);
}
};
/**
*
* `multipart/form-data` `file`
* @example 使 axios form-data
* ```js
* const form = new FormData();
* const fileStream = fs.createReadStream(filePath);
* form.append('file', fileStream);
* const uploadUrl = `http://localhost:4000/upload?dir=example&expire=600`;
* const response = await axios.post(uploadUrl, form, {
* headers: {
* ...form.getHeaders(),
* },
* maxContentLength: Infinity,
* maxBodyLength: Infinity,
* });
* ```
*
* @queryParam dir
* @queryParam expire 600
* @param req
* @param res
*/
private handleUploadFile = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
if (!req.file) {
await response.error(res, `未检测到上传文件`, 400);
return;
}
logger.debug(`检测到上传文件..`);
const uploadDir = req.query.dir?.toString() || '';
const deleteAfter = parseInt(req.query.expire as string) || 10 * 60;
const { fullPath, relativePath } = await this.FileService.saveUploadedFile(
req.file,
uploadDir
);
await this.FileService.scheduleDelete(fullPath, deleteAfter * 1000);
await response.success(res, {
message: '文件上传成功..',
filePath: fullPath,
url: relativePath,
});
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res, `文件上传失败..`, 500);
logger.error(e);
}
};
}
export default new FileController();

View File

@ -1,93 +0,0 @@
import path from 'path';
import fs from 'fs/promises';
import paths from '../../utils/core/path';
import logger from '../../utils/core/logger';
import { existsSync } from 'fs';
class FileService {
private readonly filePath: string;
constructor() {
this.filePath = paths.get('files');
logger.info(`晶灵云图数据中心初始化..数据存储在: ${this.filePath}`);
}
/**
*
* @param relativePath
*/
public async getFile(relativePath: string): Promise<string | null> {
if (!this.isValidPath(relativePath) && !this.isValidFilename(path.basename(relativePath))) {
throw new Error('非法路径请求');
}
const filePath = path.join(this.filePath, relativePath);
logger.debug(`尝试访问文件路径: ${filePath}`);
return existsSync(filePath) ? filePath : null;
}
/**
*
* @param file multer的file
* @param dir
*/
public async saveUploadedFile(
file: Express.Multer.File,
dir: string = ''
): Promise<{ fullPath: string; relativePath: string }> {
const baseDir = paths.get('uploads');
const targetDir = path.join(baseDir, dir);
if (!existsSync(targetDir)) {
await fs.mkdir(targetDir, { recursive: true });
logger.debug(`已创建上传目录: ${targetDir}`);
}
const fileName = `${Date.now()}-${file.originalname.replace(/\s+/g, '_')}`;
const finalPath = path.join(targetDir, fileName);
await fs.rename(file.path, finalPath);
logger.info(`保存上传文件: ${finalPath}`);
return {
fullPath: finalPath,
relativePath: `uploads/${dir}/${fileName}`,
};
}
/**
*
* @param filePath
* @param timeoutMs 10
*/
public async scheduleDelete(filePath: string, timeoutMs: number = 10 * 60 * 1000): Promise<void> {
setTimeout(async () => {
try {
await fs.unlink(filePath);
logger.info(`已自动删除文件: ${filePath}`);
} catch (err) {
logger.warn(`删除文件失败: ${filePath}`, err);
}
}, timeoutMs);
}
/**
*
* @param relativePath
* @private
*/
private isValidPath(relativePath: string): boolean {
try {
const normalized = path.normalize(relativePath);
let flag = true;
if (normalized.startsWith('../') && path.isAbsolute(normalized)) flag = false;
return flag;
} catch (err) {
logger.error(err);
return false;
}
}
private isValidFilename(filename: string): boolean {
return /^[a-zA-Z0-9_\-.]+$/.test(filename);
}
}
export default FileService;

View File

@ -1,45 +0,0 @@
import express from 'express';
import sampleService from './sample.service';
import response from '../../utils/core/response';
class SampleController {
private readonly router: express.Router;
constructor() {
this.router = express.Router();
this.initializeRoutes();
}
public getRouter(): express.Router {
return this.router;
}
private initializeRoutes(): void {
this.router.get('/hello', this.getHello);
this.router.post('/greet', this.postGreet);
}
private getHello = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const result = await sampleService.getHello();
await response.success(res, result);
} catch (error) {
await response.error(res, '请求失败了..', 500, error);
}
};
private postGreet = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const { name } = req.body;
if (!name) {
return response.error(res, '姓名不能为空!', 400);
}
const result = await sampleService.generateGreeting(name);
await response.success(res, result);
} catch (error) {
await response.error(res, '请求失败了..', 500, error);
}
};
}
export default new SampleController();

View File

@ -1,19 +0,0 @@
import logger from '../../utils/core/logger';
class SampleService {
public async getHello() {
logger.debug(`有个小可爱正在请求GetHello方法..`);
return { message: 'Hello World!' };
}
public async generateGreeting(name: string): Promise<object> {
logger.debug(`有个小可爱正在请求generateGreeting方法..`);
if (!name) {
logger.warn('Name is required');
throw new Error('Name is required');
}
return { message: `Hello, ${name}!` };
}
}
export default new SampleService();

View File

@ -1,62 +0,0 @@
import express from 'express';
import tools from '../../utils/modules/tools';
import response from '../../utils/core/response';
import SystemService from './system.service';
class SystemController {
private readonly router: express.Router;
constructor() {
this.router = express.Router();
this.init();
}
public getRouter(): express.Router {
return this.router;
}
private init(): void {
this.router.post('/restart', this.systemRestart);
this.router.post('/getRestartTime', this.getRestartTime);
}
/**
*
* @param req
* @param res
*/
private systemRestart = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
await response.success(res, '核心正在重启..');
await SystemService.systemRestart();
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
/**
*
* @param req
* @param res
*/
private getRestartTime = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const time = await SystemService.getRestartTime();
await response.success(res, time);
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
}
export default new SystemController();

View File

@ -1,20 +0,0 @@
import System from '../../utils/core/system';
import fs from 'fs/promises';
import logger from '../../utils/core/logger';
import path from 'path';
import paths from '../../utils/core/path';
class SystemService {
public async systemRestart() {
logger.debug(`有个小可爱正在请求重启核心..`);
await System.restart();
}
public async getRestartTime() {
logger.debug(`有个小可爱想知道核心重启花了多久..`);
const restartTimePath = path.join(paths.get('temp'), 'restart_time');
return await fs.readFile(restartTimePath, 'utf8');
}
}
export default new SystemService();

View File

@ -1,31 +0,0 @@
import express from 'express';
import TestService from './test.service';
import response from '../../utils/core/response';
import logger from '../../utils/core/logger';
class TestController {
private readonly router: express.Router;
constructor() {
this.router = express.Router();
this.initRouter();
}
public getRouter(): express.Router {
return this.router;
}
public initRouter(): void {
this.router.get('/test', this.test);
}
private test = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const result = await TestService.test();
await response.success(res, result);
} catch (err) {
logger.error(err);
}
};
}
export default new TestController();

View File

@ -1,21 +0,0 @@
import wsClientManager from '../../services/ws/wsClientManager';
import logger from '../../utils/core/logger';
class TestService {
public async test() {
try {
const testData = {
type: 'getGroupInfo',
data: {
botId: 'stdin',
groupId: 'stdin',
},
};
return await wsClientManager.sendAndWait('test', testData);
} catch (err) {
logger.error(err);
}
}
}
export default new TestService();

View File

@ -1,66 +0,0 @@
import express from 'express';
import WordsService from './words.service';
import response from '../../utils/core/response';
import tools from '../../utils/modules/tools';
class WordsController {
private readonly router: express.Router;
constructor() {
this.router = express.Router();
this.init();
}
public getRouter(): express.Router {
return this.router;
}
private init(): void {
this.router.get('/getText/:id', this.getText);
this.router.post('/reloadText', this.reloadWord);
}
/**
*
* @param req
* @param res
*/
private getText = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const id = req.params.id;
const texts = await WordsService.loadWordById(id.toString());
if (!texts || texts.length === 0) {
return await response.error(res, `文案${id}不存在或为空..`, 404);
}
const randomIndex = Math.floor(Math.random() * texts.length);
const result = texts[randomIndex];
await response.success(res, result);
} catch (e) {
await response.error(res);
}
};
/**
*
* @param req
* @param res
*/
private reloadWord = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const id = req.params.id;
const token = req.params.token;
if (tools.checkToken(token)) {
if (await WordsService.reloadWord(id.toString())) {
await response.success(res, '成功重载..');
} else {
await response.error(res, '重载失败..');
}
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
}
export default new WordsController();

View File

@ -1,66 +0,0 @@
import path from 'path';
import paths from '../../utils/core/path';
import fs from 'fs/promises';
import logger from '../../utils/core/logger';
class WordsService {
private wordCache: Record<string, string[]> = {}; //缓存
private readonly clearIntervalMs = 30 * 60 * 1000; //30min
constructor() {
this.startAutoClear();
}
private startAutoClear() {
setInterval(() => {
logger.info('[WordsService] Clearing wordCache..');
this.wordCache = {};
}, this.clearIntervalMs);
}
/**
* json到内存&
* @param id
*/
public async loadWordById(id: string): Promise<string[] | null> {
logger.info(`Loading words ${id}..`);
if (this.wordCache[id]) return this.wordCache[id];
const filePath = path.join(paths.get('words'), `${id}.json`);
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
const texts = parsed.filter((item) => typeof item === 'string');
this.wordCache[id] = texts;
return texts;
} else {
return null;
}
} catch (error) {
logger.error(`Failed to loadWordById: ${id}`);
return null;
}
}
/**
* json到内存
* @param id
*/
public async reloadWord(id: string): Promise<boolean> {
logger.info(`Reloading word: ${id}..`);
const filePath = path.join(paths.get('words'), `${id}.json`);
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
this.wordCache[id] = parsed.filter((item) => typeof item === 'string');
return true;
} else {
return false;
}
} catch (e) {
logger.error(`Failed to reloadWordById: ${id}..`);
return false;
}
}
}
export default new WordsService();

View File

@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class RootController {
@Get()
getWelcome() {
return {
message: '欢迎使用晶灵核心',
};
}
}

7
src/root/root.module.ts Normal file
View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { RootController } from './root.controller';
@Module({
controllers: [RootController],
})
export class RootModule {}

View File

@ -1,191 +0,0 @@
import Redis from 'ioredis';
import logger from '../../utils/core/logger';
import tools from '../../utils/core/tool';
import config from '../../utils/core/config';
import redisTools from '../../utils/redis/redisTools';
import Persistence from '../../utils/redis/persistence';
import IUser from '../../types/user';
class RedisService {
private client!: Redis;
private isConnected = false;
constructor() {
this.initialize().then();
}
private async initialize() {
await this.connectWithRetry();
this.setupEventListeners();
}
private async connectWithRetry(): Promise<void> {
try {
await tools.retry(
async () => {
this.client = new Redis({
host: config.get('RD_ADD'),
port: Number(config.get('RD_PORT')),
retryStrategy: (times) => {
return Math.min(times * 1000, 5000);
},
});
await this.client.ping();
this.isConnected = true;
logger.info(`Redis连接成功!位于${config.get('RD_ADD')}:${config.get('RD_PORT')}`);
},
{
maxAttempts: 5,
initialDelay: 1000,
}
);
} catch (error) {
logger.error('Redis连接失败:', error);
throw error;
}
}
/**
* Redis客户端事件监听器
*/
private setupEventListeners(): void {
this.client.on('error', (err) => {
if (!err.message.includes('ECONNREFUSED')) {
logger.error('Redis错误:', err);
}
this.isConnected = false;
});
this.client.on('ready', () => {
this.isConnected = true;
logger.debug('Redis连接就绪!');
});
this.client.on('reconnecting', () => {
logger.warn('Redis重新连接中...');
});
}
public async waitUntilReady(): Promise<void> {
if (this.isConnected) return;
return new Promise((resolve) => {
const check = () => {
if (this.isConnected) {
resolve();
} else {
setTimeout(check, 100);
}
};
check();
});
}
/**
* Redis客户端实例
* @returns {Redis} Redis客户端
* @throws fatal日志
*/
public getClient(): Redis {
if (!this.isConnected) {
logger.fatal(1, 'Redis未连接..');
}
return this.client;
}
/**
* Redis连接
* @returns {Promise<void>}
*/
public async disconnect(): Promise<void> {
await this.client.quit();
this.isConnected = false;
}
/**
* Redis
* @template T
* @param {string} key Redis键
* @param {T} value
* @param {number} [ttl]
*/
public async setObject<T>(key: string, value: T, ttl?: number): Promise<void> {
const serialized = redisTools.serialize(value);
await this.getClient().set(key, serialized);
if (ttl) {
await this.getClient().expire(key, ttl);
}
}
/**
* Redis获取对象
* @template T
* @param {string} key Redis键
* @returns {Promise<T | undefined>} undefined
*/
public async getObject<T>(key: string): Promise<T | undefined> {
const serialized = await this.getClient().get(key);
if (!serialized) return undefined;
const deserialized = redisTools.deserialize<T>(serialized);
return redisTools.reviveDates(deserialized);
}
/**
* Redis中已存在的对象
* @template T
* @param {string} key Redis键
* @param {T} updates
* @returns {Promise<T>}
*/
public async update<T>(key: string, updates: T): Promise<T> {
const existing = await this.getObject<T>(key);
if (!existing) {
logger.error(`数据${key}不存在..`);
}
const updated = { ...existing, ...updates };
await this.setObject(key, updated);
return updated;
}
/**
* Redis或本地文件获取数据
* @template T
* @param {string} key Redis键
* @param {string} fileName
* @returns {Promise<T | undefined>} undefined
*/
public async fetch<T>(key: string, fileName: string): Promise<T | undefined> {
const data = await this.getObject<T>(key);
if (data) return data;
const fromLocal = await Persistence.readDataLocal<T>(key, fileName);
if (fromLocal) {
await this.setObject(key, fromLocal);
return fromLocal;
}
logger.error(`数据${key}不存在..`);
}
/**
* Redis和本地文件
* @template T
* @param {string} key Redis键
* @param {T} data
* @param {string} fileName
*/
public async persistData<T>(key: string, data: T, fileName: string): Promise<void> {
await this.setObject(key, data);
await Persistence.writeDataLocal(key, data, fileName);
return;
}
public async test(): Promise<void> {
const user = await this.fetch<IUser>('Jerry', 'IUser');
logger.debug('User:', user);
}
}
const redisService = new RedisService();
export default redisService;

View File

@ -1,82 +0,0 @@
import { AuthenticatedSocket } from '../../types/ws';
import wsTools from '../../utils/ws/wsTools';
import { WebSocket } from 'ws';
import logger from '../../utils/core/logger';
import redisService from '../redis/redis';
import wsClientManager from './wsClientManager';
type MessageHandler = (socket: WebSocket, msg: any) => Promise<void>;
class WSMessageHandler {
private handlers: Map<string, MessageHandler>;
constructor() {
this.handlers = new Map([
['test', this.handleTest],
['ping', this.handlePing],
['pong', this.handlePong],
['reportBots', this.handleReportBots],
]);
}
async handle(socket: AuthenticatedSocket, clientId: string, msg: any) {
try {
//如果是 pendingRequests 的回包
if (msg.requestId && wsClientManager.resolvePendingRequest(msg.requestId, msg)) {
return;
}
const handler = this.handlers.get(msg.type);
if (handler) {
await handler(socket, msg);
} else {
await this.handleUnknown(socket, msg);
}
} catch (err) {
logger.error(`ws消息处理时出错 ${err}`);
await wsTools.send(socket, {
type: 'error',
message: 'error message',
});
}
}
private async handleTest(socket: WebSocket, msg: any) {
await wsTools.send(socket, {
type: 'test',
data: { status: 'ok' },
});
}
private async handlePing(socket: WebSocket, msg: any) {
await wsTools.send(socket, { type: 'pong' });
}
private async handlePong(socket: WebSocket, msg: any) {
//logger.debug(`received pong`);
}
private async handleUnknown(socket: WebSocket, msg: any) {
logger.warn(`收到未知消息类型: ${msg.type}`);
await wsTools.send(socket, {
type: 'error',
message: `未知消息类型: ${msg.type}`,
});
}
private async handleReportBots(socket: WebSocket, msg: any) {
logger.debug(`received reportBots: ${msg.data}`);
const clientId = msg.data[0].client;
const botsData = msg.data.slice(1);
await redisService.persistData('crystelfBots', botsData, clientId);
logger.debug(`保存了 ${botsData.length} 个 botclient: ${clientId}`);
}
public registerHandler(type: string, handler: MessageHandler): void {
this.handlers.set(type, handler);
}
}
const wsHandler = new WSMessageHandler();
export default wsHandler;

View File

@ -1,127 +0,0 @@
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
import { clearTimeout } from 'node:timers';
type ClientID = string;
const pendingRequests = new Map<string, (data: any) => void>();
class WSClientManager {
private clients = new Map<ClientID, WebSocket>();
/**
* ws客户端实例
* @param id
* @param socket
*/
public add(id: ClientID, socket: WebSocket) {
this.clients.set(id, socket);
}
/**
* ws客户端实例
* @param id
*/
public remove(id: ClientID) {
this.clients.delete(id);
}
/**
* ws客户端实例
* @param id
*/
public get(id: ClientID): WebSocket | undefined {
return this.clients.get(id);
}
/**
* ws客户端
* @param id ws客户端标识符
* @param data
*/
public async send(id: ClientID, data: any): Promise<boolean> {
const socket = this.clients.get(id);
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
return this.safeSend(socket, data);
}
/**
* ws发送请求&
* @param id ws客户端标识符-id
* @param data
* @param timeout 5
*/
public async sendAndWait(id: ClientID, data: any, timeout = 5000): Promise<any> {
const socket = this.clients.get(id);
if (!socket) return;
data.requestId = uuidv4();
const requestId = data.requestId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error(`${requestId}: 请求超时`));
}, timeout);
pendingRequests.set(requestId, (response) => {
clearTimeout(timer);
pendingRequests.delete(requestId);
resolve(response);
});
this.safeSend(socket, data).catch((err) => {
clearTimeout(timer);
pendingRequests.delete(requestId);
reject(err);
});
});
}
/**
*
* @param requestId
* @param data
*/
public resolvePendingRequest(requestId: string, data: any): boolean {
const callback = pendingRequests.get(requestId);
if (callback) {
pendingRequests.delete(requestId);
callback(data);
return true;
}
return false;
}
/**
* 广ws客户端
* @param data
*/
public async broadcast(data: any): Promise<void> {
const tasks = Array.from(this.clients.values()).map((socket) => {
if (socket.readyState === WebSocket.OPEN) {
return this.safeSend(socket, data);
} else {
return Promise.resolve();
}
});
await Promise.all(tasks);
}
/**
* ws客户端
* @param socket ws客户端
* @param data
* @private
*/
private async safeSend(socket: WebSocket, data: any): Promise<boolean> {
return new Promise((resolve, reject) => {
socket.send(JSON.stringify(data), (err) => {
if (err) reject(new Error('发送失败'));
else resolve(true);
});
});
}
}
const wsClientManager = new WSClientManager();
export default wsClientManager;

View File

@ -1,100 +0,0 @@
import WebSocket, { WebSocketServer } from 'ws';
import config from '../../utils/core/config';
import logger from '../../utils/core/logger';
import { AuthenticatedSocket, AuthMessage, WSMessage } from '../../types/ws';
import WsTools from '../../utils/ws/wsTools';
import wsHandler from './handler';
import { clearInterval } from 'node:timers';
import wsClientManager from './wsClientManager';
class WSServer {
private readonly wss: WebSocketServer;
private readonly port = Number(config.get('WS_PORT'));
private readonly secret = config.get('WS_SECRET');
constructor() {
this.wss = new WebSocketServer({ port: this.port });
this.init();
logger.info(`WS Server listening on ws://localhost:${this.port}`);
}
private init(): void {
this.wss.on('connection', (socket: AuthenticatedSocket, req) => {
const ip = req.socket.remoteAddress || 'unknown';
logger.info(`收到来自 ${ip} 的 WebSocket 连接请求..`);
socket.heartbeat = WsTools.setUpHeartbeat(socket);
socket.on('message', async (raw) => {
logger.debug(`Received raw message from ${ip}: ${raw.toString()}`);
const msg = WsTools.parseMessage<WSMessage>(raw);
if (!msg) return this.handleInvalidMessage(socket, ip);
await this.routeMessage(socket, msg, ip);
});
socket.on('close', () => {
logger.info(`ws断开连接 ${ip} (${socket.clientId || 'unauthenticated'})`);
this.handleDisconnect(socket);
});
socket.on('error', (err) => {
logger.error(`WS error from ${ip}: ${err.message}`);
});
});
}
private async handleInvalidMessage(socket: WebSocket, ip: string) {
logger.warn(`Invalid message received from ${ip}`);
await WsTools.send(socket, {
type: 'error',
message: 'Invalid message format',
});
}
private async routeMessage(socket: AuthenticatedSocket, msg: WSMessage, ip: string) {
if (!socket.isAuthed) {
if (this.isAuthMessage(msg)) {
logger.info(`Attempting auth from ${ip} as ${msg.clientId}`);
await this.handleAuth(socket, msg, ip);
} else {
logger.warn(`Received message before auth from ${ip}: ${JSON.stringify(msg)}`);
await this.handleInvalidMessage(socket, ip);
}
return;
}
logger.debug(`Routing message from ${socket.clientId}: ${JSON.stringify(msg)}`);
await wsHandler.handle(socket, socket.clientId!, msg);
}
private isAuthMessage(msg: WSMessage): msg is AuthMessage {
return msg.type === 'auth';
}
private async handleAuth(socket: AuthenticatedSocket, msg: AuthMessage, ip: string) {
if (msg.secret === this.secret) {
socket.isAuthed = true;
socket.clientId = msg.clientId;
wsClientManager.add(msg.clientId, socket);
logger.info(`Auth success from ${ip}, clientId: ${msg.clientId}`);
await WsTools.send(socket, { type: 'auth', success: true });
} else {
logger.warn(`Auth failed from ${ip} (invalid secret), clientId: ${msg.clientId}`);
await WsTools.send(socket, { type: 'auth', success: false });
socket.close(4001, 'Authentication failed');
}
}
private handleDisconnect(socket: AuthenticatedSocket) {
if (socket.heartbeat) clearInterval(socket.heartbeat);
if (socket.clientId) {
wsClientManager.remove(socket.clientId);
logger.info(`Removed client ${socket.clientId} from manager`);
}
}
}
const wsServer = new WSServer();
export default wsServer;

View File

@ -1,70 +0,0 @@
import WebSocket from 'ws';
import axios from 'axios';
import logger from '../utils/core/logger';
const WS_URL = 'ws://127.0.0.1:4001';
const WS_SECRET = '114514';
const CLIENT_ID = 'test';
function createWebSocketClient() {
const socket = new WebSocket(WS_URL);
socket.on('open', () => {
console.log('[WS] Connected to server');
const authPayload = {
type: 'auth',
secret: WS_SECRET,
clientId: CLIENT_ID,
};
socket.send(JSON.stringify(authPayload));
});
socket.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
console.log('[WS] Message from server:', msg);
if (msg.type === 'auth' && msg.success === true) {
socket.send(JSON.stringify({ type: 'test' }));
}
});
socket.on('close', () => {
console.log('[WS] Connection closed');
});
socket.on('error', (err) => {
console.error('[WS] Error:', err);
});
}
async function testGetAPI() {
try {
const response = await axios.get('http://localhost:4000/api/sample/hello');
console.log('[HTTP][GET] Response:', response.data);
} catch (err) {
console.error('[HTTP][GET] Error:', err);
}
}
async function testPostAPI() {
try {
const response = await axios.post('https://core.crystelf.top/api/bot/getGroupInfo', {
token: '114113',
groupId: 796070855,
});
logger.info('[HTTP][POST] Response:', response.data);
} catch (err) {
console.error('[HTTP][POST] Error:', err);
}
}
async function main() {
createWebSocketClient();
setTimeout(() => {
testPostAPI();
}, 1000);
}
main();

View File

@ -1,5 +0,0 @@
export default interface GroupInfo {
name: string;
groupId: number;
memberCount: number; //群人数
}

View File

@ -1,16 +0,0 @@
export default interface UserInfo {
qq: number;
email?: string;
labAccount?: string;
username: string;
nickname?: string;
/**
*
* number为群号number为在群内的botId
*/
manageGroups: Record<number, number[]>;
role: 'super' | 'admin' | 'user';
balance: number;
bots: number[];
}

View File

@ -1,6 +0,0 @@
interface RetryOptions {
maxAttempts: number;
initialDelay: number;
}
export default RetryOptions;

View File

@ -1,10 +0,0 @@
interface IUser {
name: string;
qq: string;
password: string;
isAdmin: boolean;
lastLogin?: Date;
createdAt: Date;
}
export default IUser;

View File

@ -1,18 +0,0 @@
import WebSocket from 'ws';
export interface AuthenticatedSocket extends WebSocket {
isAuthed?: boolean;
clientId?: string;
heartbeat?: NodeJS.Timeout;
}
export interface WSMessage {
type: string;
[key: string]: unknown;
}
export interface AuthMessage extends WSMessage {
type: 'auth';
secret: string;
clientId: string;
}

View File

@ -1,90 +0,0 @@
import simpleGit, { SimpleGit } from 'simple-git';
import paths from './path';
import logger from './logger';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
const execAsync = promisify(exec);
class AutoUpdater {
private git: SimpleGit;
private readonly repoPath: string;
constructor() {
this.git = simpleGit();
this.repoPath = paths.get('root');
}
/**
*
*/
public async checkForUpdates(): Promise<boolean> {
try {
logger.info('检查仓库更新中..');
const status = await this.git.status();
if (status.ahead > 0) {
logger.info('检测到当地仓库有未提交的更改,跳过更新..');
return false;
}
logger.info('正在获取远程仓库信息..');
await this.git.fetch();
const localBranch = status.current;
const diffSummary = await this.git.diffSummary([`${localBranch}..origin/${localBranch}`]);
if (diffSummary.files.length > 0) {
logger.info('检测到远程仓库有更新!');
logger.info('正在拉取更新..');
if (localBranch) {
await this.git.pull('origin', localBranch);
} else {
logger.error('当前分支名称未知,无法执行拉取操作..');
return false;
}
logger.info('代码更新成功,开始更新依赖..');
await this.updateDependencies();
logger.info('自动更新流程完成。');
return true;
} else {
logger.info('远程仓库没有新变化..');
return false;
}
} catch (error) {
logger.error('检查仓库更新失败: ', error);
return false;
}
}
/**
*
*/
private async updateDependencies(): Promise<void> {
try {
logger.info('执行 pnpm install...');
await execAsync('pnpm install', { cwd: this.repoPath });
logger.info('依赖安装完成。');
const pkgPath = paths.get('package');
const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
if (pkgJson.scripts?.build) {
logger.info('检测到 build 脚本,执行 pnpm build...');
await execAsync('pnpm build', { cwd: this.repoPath });
logger.info('构建完成。');
} else {
logger.info('未检测到 build 脚本,跳过构建。');
}
} catch (error) {
logger.error('更新依赖或构建失败: ', error);
}
}
}
const autoUpdater = new AutoUpdater();
export default autoUpdater;

View File

@ -1,72 +0,0 @@
import dotenv from 'dotenv';
import logger from './logger';
class ConfigManger {
private static instance: ConfigManger;
private readonly env: NodeJS.ProcessEnv;
private constructor() {
dotenv.config();
this.env = process.env;
}
/**
*
*/
public static getInstance(): ConfigManger {
if (!ConfigManger.instance) {
ConfigManger.instance = new ConfigManger();
}
return ConfigManger.instance;
}
/**
*
* @param key
* @param defaultValue
*/
public get<T = string>(key: string, defaultValue?: T): T {
const value = this.env[key];
if (value === undefined) {
if (defaultValue !== undefined) return defaultValue;
logger.fatal(1, `环境变量${key}未定义!`);
return undefined as T;
}
switch (typeof defaultValue) {
case 'number':
return Number(value) as T;
case 'boolean':
return (value === 'true') as T;
default:
return value as T;
}
}
/**
*
* @param key
* @param value
*/
public set(key: string, value: string | number | boolean): void {
this.env[key] = String(value);
logger.debug(`成功更改环境变量${key}${value}!`);
}
/**
*
*/
public check(keys: string[]): void {
keys.forEach((key) => {
if (!(key in this.env)) {
logger.fatal(1, `必须环境变量缺失:${key}`);
} else {
logger.debug(`检测到环境变量${key}!`);
}
});
}
}
const config = ConfigManger.getInstance();
export default config;

View File

@ -1,74 +0,0 @@
class date {
/**
* (格式: YYYYMMDD)
*/
public static getCurrentDate(): string {
const now = new Date();
return [
now.getFullYear(),
(now.getMonth() + 1).toString().padStart(2, '0'),
now.getDate().toString().padStart(2, '0'),
].join('');
}
/**
* (格式: HH:mm:ss)
*/
public static getCurrentTime(): string {
return new Date().toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
*
* @param formatStr (YYYY-, MM-, DD-, HH-, mm-, ss-)
* @example format('YYYY-MM-DD HH:mm:ss') => '2023-10-15 14:30:45'
*/
public static format(formatStr: string = 'YYYY-MM-DD HH:mm:ss'): string {
const now = new Date();
const replacements: Record<string, string> = {
YYYY: now.getFullYear().toString(),
MM: (now.getMonth() + 1).toString().padStart(2, '0'),
DD: now.getDate().toString().padStart(2, '0'),
HH: now.getHours().toString().padStart(2, '0'),
mm: now.getMinutes().toString().padStart(2, '0'),
ss: now.getSeconds().toString().padStart(2, '0'),
};
return formatStr.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => replacements[match]);
}
/**
*
* @param start
* @param end ()
* @param unit ('days' | 'hours' | 'minutes' | 'seconds')
*/
public static diff(
start: Date,
end: Date = new Date(),
unit: 'days' | 'hours' | 'minutes' | 'seconds' = 'days'
): number {
const msDiff = end.getTime() - start.getTime();
switch (unit) {
case 'seconds':
return Math.floor(msDiff / 1000);
case 'minutes':
return Math.floor(msDiff / (1000 * 60));
case 'hours':
return Math.floor(msDiff / (1000 * 60 * 60));
case 'days':
return Math.floor(msDiff / (1000 * 60 * 60 * 24));
default:
return msDiff;
}
}
}
export default date;

View File

@ -1,57 +0,0 @@
import path from 'path';
import fs from 'fs/promises';
import paths from './path';
import date from './date';
import logger from './logger';
import chalk from 'chalk';
class fc {
/**
*
* @param targetPath
* @param includeFile
*/
public static async createDir(
targetPath: string = '',
includeFile: boolean = false
): Promise<void> {
const root = paths.get('root');
try {
if (path.isAbsolute(targetPath)) {
const dirToCreate = includeFile ? path.dirname(targetPath) : targetPath;
await fs.mkdir(dirToCreate, { recursive: true });
//logger.debug(`成功创建绝对目录: ${dirToCreate}`);
return;
}
const fullPath = includeFile
? path.join(root, path.dirname(targetPath))
: path.join(root, targetPath);
await fs.mkdir(fullPath, { recursive: true });
//logger.debug(`成功创建相对目录: ${fullPath}`);
} catch (err) {
logger.error(`创建目录失败: ${err}`);
}
}
/**
*
* @param message
*/
public static async logToFile(message: string): Promise<void> {
const logFile = path.join(paths.get('log'), `${date.getCurrentDate()}.log`);
const logMessage = `${message}\n`;
try {
//await this.createDir(paths.get('log'));
await fs.appendFile(logFile, logMessage);
} catch (err) {
console.error(chalk.red('[LOGGER] 写入日志失败:'), err);
}
}
}
export default fc;

View File

@ -1,69 +0,0 @@
import chalk from 'chalk';
import config from './config';
import fc from './file';
import date from './date';
class Logger {
private static instance: Logger;
private readonly isDebug: boolean;
private constructor() {
this.isDebug = config.get('DEBUG', false);
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public debug(...args: any[]): void {
if (this.isDebug) {
const message = this.formatMessage('DEBUG', args);
console.log(chalk.cyan(message));
}
}
public info(...args: any[]): void {
const message = this.formatMessage('INFO', args);
console.log(chalk.green(message));
this.logToFile(message).then();
}
public warn(...args: any[]): void {
const message = this.formatMessage('WARN', args);
console.log(chalk.yellow(message));
this.logToFile(message).then();
}
public error(...args: any[]): void {
const message = this.formatMessage('ERROR', args);
console.error(chalk.red(message));
this.logToFile(message).then();
}
public fatal(exitCode: number = 1, ...args: any[]): never {
const message = this.formatMessage('FATAL', args);
console.error(chalk.red.bold(message));
this.logToFile(message).then();
process.exit(exitCode);
}
private formatMessage(level: string, args: any[]): string {
return `[${date.getCurrentTime()}][${level}] ${args
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : arg))
.join(' ')}`;
}
private async logToFile(message: string): Promise<void> {
try {
await fc.logToFile(`${message}`);
} catch (err: any) {
console.error(chalk.red(`[LOGGER] 写入日志失败: ${err.message}`));
}
}
}
const logger = Logger.getInstance();
export default logger;

View File

@ -1,98 +0,0 @@
import path from 'path';
import fs from 'fs';
import fc from './file';
class PathManager {
private static instance: PathManager;
private readonly baseDir: string;
private constructor() {
this.baseDir = path.join(__dirname, '../../..');
}
/**
*
*/
public static getInstance(): PathManager {
if (!PathManager.instance) {
PathManager.instance = new PathManager();
}
return PathManager.instance;
}
/**
*
* @param type
*/
public get(type?: PathType): string {
const mappings: Record<PathType, string> = {
root: this.baseDir,
public: path.join(this.baseDir, 'public'),
images: path.join(this.baseDir, 'public/files/image'),
log: path.join(this.baseDir, 'logs'),
config: path.join(this.baseDir, 'config'),
temp: path.join(this.baseDir, 'temp'),
userData: path.join(this.baseDir, 'private/data'),
files: path.join(this.baseDir, 'public/files'),
media: path.join(this.baseDir, 'public/files/media'),
package: path.join(this.baseDir, 'package.json'),
modules: path.join(this.baseDir, 'src/modules'),
uploads: path.join(this.baseDir, 'public/files/uploads'),
words: path.join(this.baseDir, 'private/data/word'),
};
return type ? mappings[type] : this.baseDir;
}
/**
*
*/
public init(): void {
/*
const logPath = this.get('log');
const imagePath = this.get('images');
const dataPath = this.get('userData');
const mediaPath = this.get('media');
fc.createDir(logPath, false);
fc.createDir(imagePath, false);
fc.createDir(mediaPath, false);
fc.createDir(dataPath, false);
logger.debug(`日志目录初始化: ${logPath}`);
logger.debug(`图像目录初始化: ${imagePath};${mediaPath}`);
logger.debug(`用户数据目录初始化: ${dataPath}`);
*/
const pathsToInit = [
this.get('log'),
this.get('config'),
this.get('userData'),
this.get('media'),
this.get('temp'),
this.get('uploads'),
this.get('words'),
];
pathsToInit.forEach((dirPath) => {
if (!fs.existsSync(dirPath)) {
fc.createDir(dirPath);
}
});
}
}
type PathType =
| 'root'
| 'public'
| 'images'
| 'log'
| 'config'
| 'temp'
| 'userData'
| 'files'
| 'package'
| 'media'
| 'modules'
| 'words'
| 'uploads';
const paths = PathManager.getInstance();
export default paths;

View File

@ -1,72 +0,0 @@
import { Response } from 'express';
import logger from './logger';
class response {
/**
*
* @param res Express响应对象
* @param data
* @param statusCode HTTP状态码200
*/
public static async success(res: Response, data: any, statusCode = 200) {
res.status(statusCode).json({
success: true,
data: data,
timestamp: new Date().toISOString(),
});
}
/**
*
* @param res Express响应对象
* @param message
* @param statusCode HTTP状态码500
* @param error
*/
public static async error(
res: Response,
message: string = '请求失败..',
statusCode = 500,
error?: any
) {
const response: Record<string, any> = {
success: false,
data: message,
timestamp: new Date().toISOString(),
};
logger.debug(error instanceof Error ? error.stack : error);
res.status(statusCode).json(response);
}
/**
*
* @param res Express响应对象
* @param data
* @param total
* @param page
* @param pageSize
*/
public static async pagination(
res: Response,
data: any[],
total: number,
page: number,
pageSize: number
) {
res.status(200).json({
success: true,
data,
pagination: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
timestamp: new Date().toISOString(),
});
}
}
export default response;

View File

@ -1,40 +0,0 @@
import path from 'path';
import paths from './path';
import fs from 'fs';
import logger from './logger';
const restartFile = path.join(paths.get('temp'), 'restart.timestamp');
class System {
/**
*
*/
private static markRestartTime() {
const now = Date.now();
fs.writeFileSync(restartFile, now.toString(), 'utf-8');
}
/**
*
*/
public static checkRestartTime() {
if (fs.existsSync(restartFile)) {
const prev = Number(fs.readFileSync(restartFile, 'utf-8'));
const duration = ((Date.now() - prev) / 1000 - 5).toFixed(2);
fs.unlinkSync(restartFile);
return Number(duration);
}
return null;
}
/**
*
*/
public static async restart() {
this.markRestartTime();
logger.warn('服务即将重启..');
await new Promise((r) => setTimeout(r, 300));
process.exit(0);
}
}
export default System;

View File

@ -1,48 +0,0 @@
import RetryOptions from '../../types/retry';
import logger from './logger';
let tools = {
/**
*
* @param operation
* @param options
*/
async retry(operation: () => Promise<any>, options: RetryOptions): Promise<any> {
let attempt = 0;
let lastError: any;
while (attempt < options.maxAttempts) {
try {
return await operation();
} catch (error) {
lastError = error;
attempt++;
if (attempt < options.maxAttempts) {
const delay = options.initialDelay * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
logger.error(lastError);
},
/**
*
* @param list
*/
getRandomItem<T>(list: T[]): T {
return list[Math.floor(Math.random() * list.length)];
},
/**
*
* @param min
* @param max
*/
getRandomDelay(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
},
};
export default tools;

View File

View File

@ -1,29 +0,0 @@
import express from 'express';
import response from '../core/response';
import Config from '../core/config';
let tools = {
/**
* token验证错误处理逻辑
* @param res
* @param token
*/
async tokenCheckFailed(res: express.Response, token: string): Promise<void> {
await response.error(
res,
'token验证失败..',
404,
`有个小可爱使用了错误的token:${JSON.stringify(token)}`
);
},
/**
* token是否正确
* @param token
*/
checkToken(token: string): boolean {
return token.toString() === Config.get('TOKEN').toString();
},
};
export default tools;

View File

@ -1,66 +0,0 @@
import path from 'path';
import paths from '../core/path';
import fc from '../core/file';
import logger from '../core/logger';
import fs from 'fs/promises';
class Persistence {
private static getDataPath(dataName: string, fileName: string): string {
return path.join(paths.get('userData'), dataName, `${fileName}.json`);
}
/**
*
* @param dataName
* @private
*/
private static async ensureDataPath(dataName: string): Promise<void> {
const dataPath = path.join(paths.get('userData'), dataName);
try {
await fc.createDir(dataPath, false);
} catch (err) {
logger.error(err);
}
}
/**
* json格式存储
* @param dataName
* @param data
* @param fileName
*/
public static async writeDataLocal<T>(
dataName: string,
data: T,
fileName: string
): Promise<void> {
await this.ensureDataPath(dataName);
const filePath = this.getDataPath(dataName, fileName);
try {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
logger.debug(`用户数据已持久化到本地${filePath}`);
} catch (err) {
logger.error(err);
}
}
/**
*
* @param dataName
* @param fileName
*/
public static async readDataLocal<T>(dataName: string, fileName: string): Promise<T | undefined> {
const filePath = this.getDataPath(dataName, fileName);
try {
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data) as T;
} catch (err) {
logger.error(err);
return undefined;
}
}
}
export default Persistence;

View File

@ -1,31 +0,0 @@
import logger from '../core/logger';
class redisTools {
public static serialize<T>(data: T): string {
return JSON.stringify(data);
}
public static deserialize<T>(jsonString: string): T | undefined {
try {
return JSON.parse(jsonString);
} catch (err) {
logger.error(`redis反序列化失败${err}`);
return undefined;
}
}
public static reviveDates<T>(obj: T): T {
const dateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/;
const reviver = (_: string, value: any) => {
if (typeof value === 'string' && dateRegex.test(value)) {
return new Date(value);
}
return value;
};
return JSON.parse(JSON.stringify(obj), reviver);
}
}
const redisTool = redisTools;
export default redisTool;

View File

@ -1,49 +0,0 @@
import WebSocket from 'ws';
import logger from '../core/logger';
import { setInterval } from 'node:timers';
class WsTools {
/**
*
* @param socket
* @param data
*/
static async send(socket: WebSocket, data: unknown): Promise<boolean> {
if (socket.readyState !== WebSocket.OPEN) return false;
return new Promise((resolve) => {
socket.send(JSON.stringify(data), (err) => {
resolve(!err);
});
});
}
/**
*
* @param data
*/
static parseMessage<T>(data: WebSocket.RawData): T | null {
try {
return JSON.parse(data.toString()) as T;
} catch (err) {
logger.error(err);
return null;
}
}
/**
*
* @param socket
* @param interval
*/
static setUpHeartbeat(socket: WebSocket, interval = 30000): NodeJS.Timeout {
const heartbeat = () => {
if (socket.readyState === WebSocket.OPEN) {
WsTools.send(socket, { type: 'ping' });
}
};
return setInterval(heartbeat, interval);
}
}
export default WsTools;

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -1,12 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2016",
"module": "commonjs", "module": "commonjs",
"esModuleInterop": true, "declaration": true,
"forceConsistentCasingInFileNames": true, "removeComments": true,
"strict": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "dist" "strictNullChecks": true,
}, "forceConsistentCasingInFileNames": true,
"include": ["src"] "noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
} }