Compare commits

..

14 Commits

Author SHA1 Message Date
Jerry
0f303dfc5c
Merge pull request #3 from crystelf/feat.nextjs
使用Nestjs替换express基础框架
2025-08-25 23:35:07 +08:00
839df5170a 文案模块优化 2025-08-25 23:09:51 +08:00
de264b1244 文案模块优化 2025-08-25 22:59:44 +08:00
e775bcdf77 文案模块 2025-08-25 22:42:14 +08:00
6809d07bcf 曲线救国(不是) 2025-08-25 21:45:03 +08:00
35eea17a8f 优化 2025-08-24 23:06:33 +08:00
ccda3ec271 添加部分服务 2025-08-24 22:47:47 +08:00
120bf912f1 犹化ws模块 2025-08-24 21:39:26 +08:00
3b546e50a1 ws模块 2025-08-24 17:41:43 +08:00
08f74445da 完善注释 2025-07-31 11:02:08 +08:00
7c4b933e9d feat:系统模块 2025-07-25 18:30:23 +08:00
d91f900f49 feat:系统模块 2025-07-25 16:03:12 +08:00
3e3f8029e6 nest初始化
feat:配置模块
feat:路径模块
2025-07-24 14:24:40 +08:00
4100b4f0aa nest初始化
feat:配置模块
feat:路径模块
2025-07-24 14:23:28 +08:00
99 changed files with 8608 additions and 2633 deletions

59
.gitignore vendored
View File

@ -1,15 +1,56 @@
/tmp
/out-tsc
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
/.pnp
.pnp.js
lerna-debug.log*
.env
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
/dist/
/logs/
/private/
!.vscode/settings.json
!.vscode/tasks.json
!.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" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
@ -34,7 +34,7 @@
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
@ -42,7 +42,7 @@
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
@ -50,7 +50,7 @@
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</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"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="MANUAL" />
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
<option name="myRunOnReformat" value="true" />
</component>
</project>

2
.idea/vcs.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</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,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "es5"
"trailingComma": "all"
}

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,81 @@
{
"name": "crystelf-core",
"version": "1.0.0",
"name": "nest-backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"dev": "ts-node-dev src/main.ts",
"start": "node dist/main.js",
"build": "tsc"
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"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": {
"axios": "^1.8.4",
"chalk": "4",
"compression": "^1.8.0",
"dotenv": "^16.0.0",
"express": "^4.18.0",
"ioredis": "^5.6.0",
"mkdirp": "^3.0.1",
"multer": "1.4.5-lts.2",
"simple-git": "^3.27.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.6",
"@nestjs/platform-ws": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@nestjs/websockets": "^11.1.6",
"axios": "1.11.0",
"ioredis": "^5.6.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"simple-git": "^3.28.0",
"ssh2": "^1.16.0",
"uuid": "^11.1.0",
"ws": "^8.18.1"
"ws": "^8.18.3"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/express": "^4.17.0",
"@types/mkdirp": "^2.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^18.0.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.16.4",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.18.1",
"prettier": "^3.5.3",
"ts-node-dev": "^2.0.0",
"typescript": "^5.0.0"
"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"
}
}

6856
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { RootModule } from './root/root.module';
import { AppConfigModule } from './config/config.module';
import { PathModule } from './core/path/path.module';
import { SystemModule } from './core/system/system.module';
import { ToolsModule } from './core/tools/tools.module';
import { AutoUpdateModule } from './core/auto-update/auto-update.module';
import { PersistenceModule } from './core/persistence/persistence.module';
import { RedisModule } from './core/redis/redis.module';
import { WsModule } from './core/ws/ws.module';
import { SystemWebModule } from './modules/system/systemWeb.module';
import { BotModule } from './modules/bot/bot.module';
import { CdnModule } from './modules/cdn/cdn.module';
import { WordsModule } from './modules/words/words.module';
@Module({
imports: [
RootModule,
AppConfigModule,
PathModule,
SystemModule,
ToolsModule,
PersistenceModule,
AutoUpdateModule,
RedisModule,
WsModule,
SystemWebModule,
BotModule,
CdnModule,
WordsModule,
],
})
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,31 @@
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,29 @@
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: '欢迎使用晶灵核心 | Welcome to use crystelf-core',
})),
);
}
}

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,52 @@
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();
}
/**
*
* @param key
* @param defaultValue
*/
public 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,10 @@
import { Module } from '@nestjs/common';
import { AutoUpdateService } from './auto-update.service';
import { PathModule } from '../path/path.module';
@Module({
imports: [PathModule],
providers: [AutoUpdateService],
exports: [AutoUpdateService],
})
export class AutoUpdateModule {}

View File

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

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PathModule } from '../path/path.module';
import { FilesService } from './files.service';
@Module({
imports: [PathModule],
providers: [FilesService],
exports: [FilesService],
})
export class FilesModule {}

View File

@ -0,0 +1,33 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import { promises as fs } from 'fs';
import { PathService } from '../path/path.service';
@Injectable()
export class FilesService {
private readonly logger = new Logger(FilesService.name);
constructor(
@Inject(PathService)
private readonly paths: PathService,
) {}
/**
*
* @param targetPath
* @param includeFile
*/
async createDir(targetPath = '', includeFile = false): Promise<void> {
const root = this.paths.get('root');
try {
const dirToCreate = path.isAbsolute(targetPath)
? includeFile
? path.dirname(targetPath)
: targetPath
: path.join(root, includeFile ? path.dirname(targetPath) : targetPath);
await fs.mkdir(dirToCreate, { recursive: true });
} catch (err) {
this.logger.error(`创建目录失败: ${err}`);
}
}
}

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,109 @@
import { Injectable } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs';
import { Logger } from '@nestjs/common';
@Injectable()
export class PathService {
private readonly baseDir: string;
private readonly logger = new Logger(PathService.name);
constructor() {
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'),
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'),
package: path.join(this.baseDir, 'package.json'),
modules: path.join(this.baseDir, 'src/modules'),
words: path.join(this.baseDir, 'private/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('temp'),
this.get('public'),
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'
| 'log'
| 'config'
| 'temp'
| 'userData'
| 'package'
| 'modules'
| 'words';

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PersistenceService } from './persistence.service';
import { PathModule } from '../path/path.module';
import { FilesModule } from '../files/files.module';
@Module({
imports: [PathModule, FilesModule],
providers: [PersistenceService],
exports: [PersistenceService],
})
export class PersistenceModule {}

View File

@ -0,0 +1,77 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs/promises';
import { PathService } from '../path/path.service';
import { FilesService } from '../files/files.service';
@Injectable()
export class PersistenceService {
private readonly logger = new Logger(PersistenceService.name);
private getDataPath(dataName: string, fileName: string): string {
return path.join(this.paths.get('userData'), dataName, `${fileName}.json`);
}
constructor(
@Inject(PathService)
private readonly paths: PathService,
@Inject(FilesService)
private readonly fileService: FilesService,
) {}
/**
*
* @param dataName /
* @private
*/
private async ensureDataPath(dataName: string): Promise<void> {
const dataPath = path.join(this.paths.get('userData'), dataName);
try {
await this.fileService.createDir(dataPath, false);
} catch (err) {
this.logger.error('目录创建失败:', err);
}
}
/**
*
* @param dataName ->
* @param data
* @param fileName
*/
public 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');
this.logger.debug(`用户数据已持久化到本地: ${filePath}`);
} catch (err) {
this.logger.error('写入失败:', err);
}
}
/**
*
* @param dataName ->
* @param fileName
*/
public 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) {
this.logger.error('读取失败:', err);
return undefined;
}
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { AppConfigModule } from '../../config/config.module';
import { ToolsModule } from '../tools/tools.module';
import { PersistenceModule } from '../persistence/persistence.module';
@Module({
imports: [AppConfigModule, ToolsModule, PersistenceModule],
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,185 @@
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import { RedisUtils } from './redis.utils';
import { AppConfigService } from '../../config/config.service';
import { ToolsService } from '../tools/tools.service';
import IUser from '../../types/user';
import { PersistenceService } from '../persistence/persistence.service';
@Injectable()
export class RedisService implements OnModuleInit {
private readonly logger = new Logger(RedisService.name);
private client!: Redis;
private isConnected = false;
constructor(
@Inject(AppConfigService)
private readonly config: AppConfigService,
@Inject(ToolsService)
private readonly tools: ToolsService,
@Inject(PersistenceService)
private readonly Persistence: PersistenceService,
) {}
async onModuleInit() {
await this.connectWithRetry();
this.setupEventListeners();
}
private async connectWithRetry(): Promise<void> {
try {
await this.tools.retry(
async () => {
this.client = new Redis({
host: this.config.get('RD_ADD'),
port: Number(this.config.get('RD_PORT')),
retryStrategy: (times: number) => Math.min(times * 1000, 5000),
});
await this.client.ping();
this.isConnected = true;
this.logger.log(
`Redis连接成功! 位于 ${this.config.get('RD_ADD')}:${this.config.get('RD_PORT')}`,
);
},
{
maxAttempts: 5,
initialDelay: 1000,
},
);
} catch (error) {
this.logger.error('Redis连接失败:', error);
throw error;
}
}
private setupEventListeners(): void {
this.client.on('error', (err) => {
if (!err.message.includes('ECONNREFUSED')) {
this.logger.error('Redis错误:', err);
}
this.isConnected = false;
});
this.client.on('ready', () => {
this.isConnected = true;
this.logger.log('Redis连接就绪!');
});
this.client.on('reconnecting', () => {
this.logger.warn('Redis重新连接中...');
});
}
/**
* redis就绪
*/
public async waitUntilReady(): Promise<void> {
if (this.isConnected) return;
return new Promise((resolve) => {
const check = () =>
this.isConnected ? resolve() : setTimeout(check, 100);
check();
});
}
/**
* redis实例
*/
public getClient(): Redis {
if (!this.isConnected) {
this.logger.error('Redis未连接');
}
return this.client;
}
/**
*
*/
public async disconnect(): Promise<void> {
await this.client.quit();
this.isConnected = false;
}
/**
*
* @param key
* @param value
* @param ttl
*/
public async setObject<T>(
key: string,
value: T,
ttl?: number,
): Promise<void> {
const serialized = RedisUtils.serialize(value);
await this.client.set(key, serialized);
if (ttl) {
await this.client.expire(key, ttl);
}
}
/**
* redis中获取对象
* @param key
*/
public async getObject<T>(key: string): Promise<T | undefined> {
const serialized = await this.client.get(key);
if (!serialized) return undefined;
const deserialized = RedisUtils.deserialize<T>(serialized);
return RedisUtils.reviveDates(deserialized);
}
/**
* redis中的呃对象
* @param key
* @param updates
*/
public async update<T>(key: string, updates: T): Promise<T> {
const existing = await this.getObject<T>(key);
if (!existing) {
this.logger.error(`数据${key}不存在`);
}
const updated = { ...existing, ...updates };
await this.setObject(key, updated);
return updated;
}
/**
* redis获取对象
* @param key /
* @param fileName
*/
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 this.Persistence.readDataLocal<T>(key, fileName);
if (fromLocal) {
await this.setObject(key, fromLocal);
return fromLocal;
}
this.logger.error(`数据${key}不存在`);
}
/**
*
* @param key
* @param data
* @param fileName
*/
public async persistData<T>(
key: string,
data: T,
fileName: string,
): Promise<void> {
await this.setObject(key, data);
await this.Persistence.writeDataLocal(key, data, fileName);
}
public async test(): Promise<void> {
const user = await this.fetch<IUser>('Jerry', 'IUser');
this.logger.debug('User:', user);
}
}

View File

@ -1,11 +1,13 @@
import logger from '../core/logger';
import { Logger } from '@nestjs/common';
class redisTools {
public static serialize<T>(data: T): string {
const logger = new Logger('RedisUtils');
export class RedisUtils {
static serialize<T>(data: T): string {
return JSON.stringify(data);
}
public static deserialize<T>(jsonString: string): T | undefined {
static deserialize<T>(jsonString: string): T | undefined {
try {
return JSON.parse(jsonString);
} catch (err) {
@ -14,7 +16,7 @@ class redisTools {
}
}
public static reviveDates<T>(obj: T): T {
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) => {
@ -26,6 +28,3 @@ class redisTools {
return JSON.parse(JSON.stringify(obj), reviver);
}
}
const redisTool = redisTools;
export default redisTool;

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SystemService } from './system.service';
import { PathModule } from '../path/path.module';
import { AutoUpdateModule } from '../auto-update/auto-update.module';
@Module({
imports: [PathModule, AutoUpdateModule],
providers: [SystemService],
exports: [SystemService],
})
export class SystemModule {}

View File

@ -0,0 +1,68 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs';
import { PathService } from '../path/path.service';
import { AutoUpdateService } from '../auto-update/auto-update.service';
import * as process from 'node:process';
@Injectable()
export class SystemService {
private readonly logger = new Logger(SystemService.name);
private readonly restartFile: string;
constructor(
@Inject(PathService)
private readonly pathService: PathService,
@Inject(AutoUpdateService)
private readonly autoUpdateService: AutoUpdateService,
) {
this.restartFile = path.join(
this.pathService.get('temp'),
'restart.timestamp',
);
}
/**
*
*/
private markRestartTime(): void {
const now = Date.now();
fs.writeFileSync(this.restartFile, now.toString(), 'utf-8');
this.logger.debug(`记录重启时间戳: ${now}`);
}
/**
*
*/
checkRestartTime(): number | null {
if (fs.existsSync(this.restartFile)) {
const prev = Number(fs.readFileSync(this.restartFile, 'utf-8'));
const duration = ((Date.now() - prev) / 1000 - 5).toFixed(2);
fs.unlinkSync(this.restartFile);
this.logger.debug(`检测到重启,耗时: ${duration}`);
return Number(duration);
}
return null;
}
/**
*
*/
async restart(): Promise<void> {
this.markRestartTime();
this.logger.warn('服务即将重启..');
await new Promise((resolve) => setTimeout(resolve, 300));
process.exit(0);
}
/**
*
*/
async checkUpdate(): Promise<void> {
const updated = await this.autoUpdateService.checkForUpdates();
if (updated) {
this.logger.warn('系统代码已更新,正在重启..');
process.exit(1);
}
}
}

View File

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

View File

@ -0,0 +1,37 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
Inject,
} from '@nestjs/common';
import { ToolsService } from './tools.service';
/**
* token验证守卫
*/
@Injectable()
export class TokenAuthGuard implements CanActivate {
private readonly logger = new Logger(TokenAuthGuard.name);
constructor(
@Inject(ToolsService) private readonly toolsService: ToolsService,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.body?.token || request.headers['x-token']; //两种传入方式
if (!token) {
this.logger.warn('请求缺少 token');
throw new UnauthorizedException('缺少 token');
}
if (!this.toolsService.checkToken(token)) {
this.toolsService.tokenCheckFailed(token);
}
return true;
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ToolsService } from './tools.service';
import { AppConfigModule } from '../../config/config.module';
@Module({
imports: [AppConfigModule],
providers: [ToolsService],
exports: [ToolsService],
})
export class ToolsModule {}

View File

@ -0,0 +1,82 @@
import {
Inject,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { RetryOptions } from './retry-options.interface';
import { AppConfigService } from '../../config/config.service';
@Injectable()
export class ToolsService {
private readonly logger = new Logger(ToolsService.name);
constructor(
@Inject(AppConfigService)
private readonly config: AppConfigService,
) {}
/**
*
* @param operation
* @param options
*/
async retry<T>(
operation: () => Promise<T>,
options: RetryOptions,
): Promise<T> {
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));
}
}
}
this.logger.error('重试失败', lastError);
throw lastError;
}
/**
*
*/
getRandomItem<T>(list: T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
/**
*
*/
getRandomDelay(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* token
* @param token token
*/
checkToken(token: string): boolean {
const expected = this.config.get<string>('TOKEN');
if (!expected) {
this.logger.error('环境变量 TOKEN 未配置,无法进行验证!');
throw new UnauthorizedException('系统配置错误,缺少 TOKEN');
}
return token === expected;
}
/**
* token
* @param token token
*/
tokenCheckFailed(token: string): never {
this.logger.warn(`有个小可爱使用了错误的 token: ${JSON.stringify(token)}`);
throw new UnauthorizedException('token 验证失败..');
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { WsTools } from '../ws.tools';
import { IMessageHandler } from 'src/types/ws/ws.handlers.interface';
import { AuthenticatedSocket } from '../../../types/ws/ws.interface';
@Injectable()
export class PingHandler implements IMessageHandler {
type = 'ping';
async handle(socket: AuthenticatedSocket, msg: any) {
await WsTools.send(socket, { type: 'pong' });
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { AuthenticatedSocket } from '../../../types/ws/ws.interface';
import { IMessageHandler } from '../../../types/ws/ws.handlers.interface';
@Injectable()
export class PongHandler implements IMessageHandler {
type = 'pong';
async handle(socket: AuthenticatedSocket, msg: any) {
//this.logger.debug(`收到 pong 消息: ${JSON.stringify(msg)}`);
}
}

View File

@ -0,0 +1,26 @@
import { Inject, Injectable } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { IMessageHandler } from '../../../types/ws/ws.handlers.interface';
import { RedisService } from '../../redis/redis.service';
import { AuthenticatedSocket } from '../../../types/ws/ws.interface';
@Injectable()
export class ReportBotsHandler implements IMessageHandler {
type = 'reportBots';
private readonly logger = new Logger(ReportBotsHandler.name);
constructor(
@Inject(RedisService)
private readonly redisService: RedisService,
) {}
async handle(socket: AuthenticatedSocket, msg: any) {
this.logger.debug(`received reportBots: ${msg.data}`);
const clientId = msg.data[0].client;
const botsData = msg.data.slice(1);
await this.redisService.persistData('crystelfBots', botsData, clientId);
this.logger.debug(
`保存了 ${botsData.length} 个 botclient: ${clientId}`,
);
}
}

View File

@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { WsTools } from '../ws.tools';
import { IMessageHandler } from '../../../types/ws/ws.handlers.interface';
import { AuthenticatedSocket } from '../../../types/ws/ws.interface';
@Injectable()
export class TestHandler implements IMessageHandler {
type = 'test';
async handle(socket: AuthenticatedSocket, msg: any) {
await WsTools.send(socket, {
type: 'test',
data: { status: 'ok' },
});
}
}

View File

@ -0,0 +1,18 @@
import { Injectable, Logger } from '@nestjs/common';
import { WsTools } from '../ws.tools';
import { IMessageHandler } from '../../../types/ws/ws.handlers.interface';
import { AuthenticatedSocket } from '../../../types/ws/ws.interface';
@Injectable()
export class UnknownHandler implements IMessageHandler {
type = 'unknown';
private readonly logger = new Logger(UnknownHandler.name);
async handle(socket: AuthenticatedSocket, msg: any) {
this.logger.warn(`收到未知消息类型: ${msg.type}`);
await WsTools.send(socket, {
type: 'error',
message: `未知消息类型: ${msg.type}`,
});
}
}

View File

@ -1,56 +1,60 @@
import { Injectable } from '@nestjs/common';
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 {
/**
*
*/
@Injectable()
export class WsClientManager {
private clients = new Map<ClientID, WebSocket>();
/**
* ws客户端实例
* @param id
* @param socket
*
* @param id
* @param socket
*/
public add(id: ClientID, socket: WebSocket) {
add(id: ClientID, socket: WebSocket) {
this.clients.set(id, socket);
}
/**
* ws客户端实例
* @param id
*
* @param id
*/
public remove(id: ClientID) {
remove(id: ClientID) {
this.clients.delete(id);
}
/**
* ws客户端实
* @param id
*
* @param id
*/
public get(id: ClientID): WebSocket | undefined {
get(id: ClientID): WebSocket | undefined {
return this.clients.get(id);
}
/**
* ws客户
* @param id ws客户端标识符
* @param data
*
* @param id
* @param data
*/
public async send(id: ClientID, data: any): Promise<boolean> {
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
*
* @param id
* @param data
* @param timeout
*/
public async sendAndWait(id: ClientID, data: any, timeout = 5000): Promise<any> {
async sendAndWait(id: ClientID, data: any, timeout = 5000): Promise<any> {
const socket = this.clients.get(id);
if (!socket) return;
@ -79,10 +83,10 @@ class WSClientManager {
/**
*
* @param requestId
* @param data
* @param requestId id
* @param data
*/
public resolvePendingRequest(requestId: string, data: any): boolean {
resolvePendingRequest(requestId: string, data: any): boolean {
const callback = pendingRequests.get(requestId);
if (callback) {
pendingRequests.delete(requestId);
@ -93,10 +97,10 @@ class WSClientManager {
}
/**
* 广ws客户端
* @param data
* 广
* @param data
*/
public async broadcast(data: any): Promise<void> {
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);
@ -108,9 +112,9 @@ class WSClientManager {
}
/**
* ws客户端
* @param socket ws客户端
* @param data
*
* @param socket
* @param data
* @private
*/
private async safeSend(socket: WebSocket, data: any): Promise<boolean> {
@ -122,6 +126,3 @@ class WSClientManager {
});
}
}
const wsClientManager = new WSClientManager();
export default wsClientManager;

View File

@ -0,0 +1,56 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { WsTools } from './ws.tools';
import { WsClientManager } from './ws-client.manager';
import { IMessageHandler } from '../../types/ws/ws.handlers.interface';
import { AuthenticatedSocket } from '../../types/ws/ws.interface';
@Injectable()
export class WsMessageHandler {
private readonly logger = new Logger(WsMessageHandler.name);
private handlers = new Map<string, IMessageHandler>();
constructor(
private readonly wsClientManager: WsClientManager,
@Inject('WS_HANDLERS') handlers: IMessageHandler[],
) {
handlers.forEach((h) => this.handlers.set(h.type, h));
this.logger.log(`已注册 ${handlers.length} 个 WS handler`);
}
async handle(socket: AuthenticatedSocket, clientId: string, msg: any) {
try {
// 如果是 pendingRequests 的回包
if (
msg.requestId &&
this.wsClientManager.resolvePendingRequest(msg.requestId, msg)
) {
return;
}
const handler =
this.handlers.get(msg.type) || this.handlers.get('unknown');
if (handler) {
await handler.handle(socket, msg);
} else {
await this.handleUnknown(socket, msg);
}
} catch (err) {
this.logger.error(`ws消息处理时出错 ${err}`);
await WsTools.send(socket, {
type: 'error',
message: 'error message',
});
}
}
private async handleUnknown(socket: AuthenticatedSocket, msg: any) {
this.logger.warn(`收到未知消息类型: ${msg.type}`);
await WsTools.send(socket, {
type: 'error',
message: `未知消息类型: ${msg.type}`,
});
}
public registerHandler(handler: IMessageHandler): void {
this.handlers.set(handler.type, handler);
}
}

153
src/core/ws/ws.gateway.ts Normal file
View File

@ -0,0 +1,153 @@
import {
OnGatewayConnection,
OnGatewayDisconnect,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Inject, Logger } from '@nestjs/common';
import { Server, WebSocket } from 'ws';
import { WsTools } from './ws.tools';
import { WsClientManager } from './ws-client.manager';
import {
AuthenticatedSocket,
AuthMessage,
WSMessage,
} from '../../types/ws/ws.interface';
import { AppConfigService } from '../../config/config.service';
import { WsMessageHandler } from './ws-message.handler';
@WebSocketGateway(7001, {
cors: { origin: '*' },
driver: 'ws',
})
export class WsGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(WsGateway.name);
private readonly secret: string | undefined;
@WebSocketServer()
server: Server;
constructor(
@Inject(AppConfigService)
private readonly configService: AppConfigService,
@Inject(WsClientManager)
private readonly wsClientManager: WsClientManager,
@Inject(WsMessageHandler)
private readonly wsMessageHandler: WsMessageHandler,
) {
this.secret = this.configService.get<string>('WS_SECRET');
}
/**
*
* @param client
* @param req
*/
async handleConnection(client: AuthenticatedSocket, req: any) {
const ip = req.socket.remoteAddress || 'unknown';
this.logger.log(`收到来自 ${ip} 的 WebSocket 连接请求..`);
client.heartbeat = WsTools.setUpHeartbeat(client);
client.on('message', async (raw) => {
this.logger.debug(`Received raw message from ${ip}: ${raw.toString()}`);
const msg = WsTools.parseMessage<WSMessage>(raw);
if (!msg) return this.handleInvalidMessage(client, ip);
await this.routeMessage(client, msg, ip);
});
client.on('error', (err) => {
this.logger.error(`WS error from ${ip}: ${err.message}`);
});
}
/**
*
* @param client
*/
async handleDisconnect(client: AuthenticatedSocket) {
if (client.heartbeat) clearInterval(client.heartbeat);
if (client.clientId) {
this.wsClientManager.remove(client.clientId);
this.logger.log(`Removed client ${client.clientId} from manager`);
}
}
/**
*
* @param client
* @param ip
* @private
*/
private async handleInvalidMessage(client: WebSocket, ip: string) {
this.logger.warn(`Invalid message received from ${ip}`);
await WsTools.send(client, {
type: 'error',
message: 'Invalid message format',
});
}
/**
*
* @param client
* @param msg
* @param ip
* @private
*/
private async routeMessage(
client: AuthenticatedSocket,
msg: WSMessage,
ip: string,
) {
if (!client.isAuthed) {
if (this.isAuthMessage(msg)) {
this.logger.log(`Attempting auth from ${ip} as ${msg.clientId}`);
await this.handleAuth(client, msg, ip);
} else {
this.logger.warn(
`Received message before auth from ${ip}: ${JSON.stringify(msg)}`,
);
await this.handleInvalidMessage(client, ip);
}
return;
}
this.logger.debug(
`Routing message from ${client.clientId}: ${JSON.stringify(msg)}`,
);
await this.wsMessageHandler.handle(client, client.clientId!, msg);
}
private isAuthMessage(msg: WSMessage): msg is AuthMessage {
return msg.type === 'auth';
}
/**
*
* @param client
* @param msg
* @param ip
* @private
*/
private async handleAuth(
client: AuthenticatedSocket,
msg: AuthMessage,
ip: string,
) {
if (msg.secret === this.secret) {
client.isAuthed = true;
client.clientId = msg.clientId;
this.wsClientManager.add(msg.clientId, client);
this.logger.log(`Auth success from ${ip}, clientId: ${msg.clientId}`);
await WsTools.send(client, { type: 'auth', success: true });
} else {
this.logger.warn(
`Auth failed from ${ip} (invalid secret), clientId: ${msg.clientId}`,
);
await WsTools.send(client, { type: 'auth', success: false });
client.close(4001, 'Authentication failed');
}
}
}

44
src/core/ws/ws.module.ts Normal file
View File

@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { WsClientManager } from './ws-client.manager';
import { AppConfigModule } from '../../config/config.module';
import { WsMessageHandler } from './ws-message.handler';
import { TestHandler } from './handlers/test.handler';
import { PingHandler } from './handlers/ping.handler';
import { PongHandler } from './handlers/pong.handler';
import { ReportBotsHandler } from './handlers/report-bots.handler';
import { UnknownHandler } from './handlers/unknown.handler';
import { RedisModule } from '../redis/redis.module';
@Module({
imports: [AppConfigModule, RedisModule],
providers: [
WsGateway,
WsClientManager,
WsMessageHandler,
TestHandler,
PingHandler,
PongHandler,
ReportBotsHandler,
UnknownHandler,
{
provide: 'WS_HANDLERS',
useFactory: (
test: TestHandler,
ping: PingHandler,
pong: PongHandler,
reportBots: ReportBotsHandler,
unknown: UnknownHandler,
) => [test, ping, pong, reportBots, unknown],
inject: [
TestHandler,
PingHandler,
PongHandler,
ReportBotsHandler,
UnknownHandler,
],
},
],
exports: [WsClientManager, WsMessageHandler, WsGateway],
})
export class WsModule {}

47
src/core/ws/ws.tools.ts Normal file
View File

@ -0,0 +1,47 @@
import type { WebSocket, RawData } from 'ws';
import { Logger } from '@nestjs/common';
export class WsTools {
private static readonly logger = new Logger(WsTools.name);
static async send(socket: WebSocket, data: unknown): Promise<boolean> {
if (socket.readyState !== 1) {
this.logger.warn('尝试向非 OPEN 状态的 socket 发送消息,已丢弃');
return false;
}
return new Promise((resolve) => {
try {
socket.send(JSON.stringify(data), (err) => {
if (err) {
this.logger.error(`WS send error: ${err.message}`);
resolve(false);
} else {
resolve(true);
}
});
} catch (err: any) {
this.logger.error(`WS send exception: ${err.message}`);
resolve(false);
}
});
}
static parseMessage<T>(data: RawData): T | null {
try {
return JSON.parse(data.toString()) as T;
} catch (err) {
this.logger.error(`WS parse error: ${err}`);
return null;
}
}
static setUpHeartbeat(socket: WebSocket, interval = 30000): NodeJS.Timeout {
const heartbeat = async () => {
if (socket.readyState === 1) {
await WsTools.send(socket, { type: 'ping' });
}
};
return setInterval(heartbeat, interval);
}
}

View File

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

View File

@ -1,156 +1,70 @@
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';
import { Body, Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags, ApiBody } from '@nestjs/swagger';
import { BotService } from './bot.service';
import { WsClientManager } from 'src/core/ws/ws-client.manager';
import { TokenAuthGuard } from 'src/core/tools/token-auth.guard';
import {
BroadcastDto,
GroupInfoDto,
SendMessageDto,
TokenDto,
} from './bot.dto';
class BotController {
private readonly router: express.Router;
@ApiTags('Bot相关操作')
@Controller('bot')
export class BotController {
constructor(
@Inject(BotService)
private readonly botService: BotService,
@Inject(WsClientManager)
private readonly wsClientManager: WsClientManager,
) {}
constructor() {
this.router = express.Router();
this.init();
@Post('getBotId')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '获取当前连接到核心的全部 botId 数组' })
async postBotsId(@Body() dto: TokenDto) {
return this.botService.getBotId();
}
public getRouter(): express.Router {
return this.router;
@Post('getGroupInfo')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '获取群聊信息' })
@ApiBody({ type: GroupInfoDto })
async postGroupInfo(@Body() dto: GroupInfoDto) {
return this.botService.getGroupInfo({ groupId: dto.groupId });
}
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);
@Post('reportBots')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '广播:要求同步群聊信息和 bot 连接情况' })
async reportBots(@Body() dto: TokenDto) {
const sendMessage = {
type: 'reportBots',
data: {},
};
await this.wsClientManager.broadcast(sendMessage);
return { message: '正在请求同步 bot 数据..' };
}
/**
* 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);
@Post('sendMessage')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '发送消息到群聊', description: '自动选择bot发送' })
@ApiBody({ type: SendMessageDto })
async sendMessage(@Body() dto: SendMessageDto) {
const flag = await this.botService.sendMessage(dto.groupId, dto.message);
if (!flag) {
return { message: '消息发送失败' };
}
};
return { message: '消息发送成功' };
}
/**
*
* @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);
}
};
@Post('broadcast')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '广播消息到全部群聊', description: '随机延迟' })
@ApiBody({ type: BroadcastDto })
async smartBroadcast(@Body() dto: BroadcastDto) {
await this.botService.broadcastToAllGroups(dto.message);
return { message: '广播任务已开始,正在后台执行..' };
}
}
export default new BotController();

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
export class TokenDto {
@ApiProperty({ description: '访问核心的鉴权 token' })
token: string;
}
export class GroupInfoDto extends TokenDto {
@ApiProperty({ description: '群号', example: 114514 })
groupId: number;
}
export class SendMessageDto extends GroupInfoDto {
@ApiProperty({ description: '要发送的消息', example: 'Ciallo(∠・ω< )⌒★' })
message: string;
}
export class BroadcastDto extends TokenDto {
@ApiProperty({
description: '要广播的消息',
example: '全体目光向我看齐!我宣布个事儿..',
})
message: string;
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { RedisModule } from '../../core/redis/redis.module';
import { WsModule } from '../../core/ws/ws.module';
import { ToolsModule } from '../../core/tools/tools.module';
import { PathModule } from '../../core/path/path.module';
import { BotController } from './bot.controller';
import { BotService } from './bot.service';
@Module({
imports: [RedisModule, WsModule, ToolsModule, PathModule],
controllers: [BotController],
providers: [BotService],
})
export class BotModule {}

View File

@ -1,18 +1,32 @@
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';
import { Inject, Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs/promises';
import * as path from 'path';
import { RedisService } from 'src/core/redis/redis.service';
import { WsClientManager } from 'src/core/ws/ws-client.manager';
import { ToolsService } from '../../core/tools/tools.service';
import { PathService } from '../../core/path/path.service';
@Injectable()
export class BotService {
private readonly logger = new Logger(BotService.name);
constructor(
@Inject(RedisService)
private readonly redisService: RedisService,
@Inject(WsClientManager)
private readonly wsClientManager: WsClientManager,
@Inject(ToolsService)
private readonly tools: ToolsService,
@Inject(PathService)
private readonly paths: PathService,
) {}
class BotService {
/**
* botId数组
*/
public async getBotId(): Promise<{ uin: number; nickName: string }[]> {
logger.debug('GetBotId..');
const userPath = paths.get('userData');
async getBotId(): Promise<{ uin: number; nickName: string }[]> {
this.logger.debug('正在请求获取在线的bot..');
const userPath = this.paths.get('userData');
const botsPath = path.join(userPath, '/crystelfBots');
const dirData = await fs.readdir(botsPath);
const uins: { uin: number; nickName: string }[] = [];
@ -21,7 +35,7 @@ class BotService {
if (!fileName.endsWith('.json')) continue;
try {
const raw = await redisService.fetch('crystelfBots', fileName);
const raw = await this.redisService.fetch('crystelfBots', fileName);
if (!raw || !Array.isArray(raw)) continue;
for (const bot of raw) {
@ -32,10 +46,9 @@ class BotService {
}
}
} catch (err) {
logger.error(`读取或解析 ${fileName} 出错: ${err}`);
this.logger.error(`读取或解析 ${fileName} 出错: ${err}`);
}
}
logger.debug(uins);
return uins;
}
@ -43,15 +56,16 @@ class BotService {
*
* @param data
*/
public async getGroupInfo(data: {
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));
this.logger.debug(`正在尝试获取${data.groupId}的信息..)`);
const sendBot: number | undefined =
data.botId ?? (await this.getGroupBot(data.groupId));
if (!sendBot) {
logger.warn(`不存在能向群聊${data.groupId}发送消息的Bot!`);
this.logger.warn(`不存在能向群聊${data.groupId}发送消息的Bot!`);
return undefined;
}
@ -65,7 +79,10 @@ class BotService {
};
if (sendData.data.clientID) {
const returnData = await wsClientManager.sendAndWait(sendData.data.clientID, sendData);
const returnData = await this.wsClientManager.sendAndWait(
sendData.data.clientID,
sendData,
);
return returnData ?? undefined;
}
return undefined;
@ -76,47 +93,42 @@ class BotService {
* @param groupId
* @param message
*/
public async sendMessage(groupId: number, message: string): Promise<boolean> {
logger.info(`发送${message}${groupId}..`);
async sendMessage(groupId: number, message: string): Promise<boolean> {
this.logger.log(`发送${message}${groupId}..`);
const sendBot = await this.getGroupBot(groupId);
if (!sendBot) {
logger.warn(`不存在能向群聊${groupId}发送消息的Bot!`);
this.logger.warn(`不存在能向群聊${groupId}发送消息的Bot!`);
return false;
}
const client = await this.getBotClient(sendBot);
if (!client) {
logger.warn(`不存在${sendBot}对应的client!`);
this.logger.warn(`不存在${sendBot}对应的client!`);
return false;
}
const sendData = {
type: 'sendMessage',
data: {
botId: sendBot,
groupId: groupId,
clientId: client,
message: message,
},
data: { botId: sendBot, groupId, clientId: client, message },
};
await wsClientManager.send(client, sendData);
await this.wsClientManager.send(client, sendData);
return true;
}
/**
* 广
* 广
* @param message 广
*/
// TODO 添加群聊信誉分机制低于30分的群聊不播报等..
public async broadcastToAllGroups(message: string): Promise<void> {
const userPath = paths.get('userData');
async broadcastToAllGroups(message: string): Promise<void> {
const userPath = this.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();
const groupMap: Map<number, { botId: number; clientId: string }[]> =
new Map();
this.logger.log(`广播消息:${message}`);
for (const fileName of dirData) {
if (!fileName.endsWith('.json')) continue;
const clientId = path.basename(fileName, '.json');
const botList = await redisService.fetch('crystelfBots', fileName);
const botList = await this.redisService.fetch('crystelfBots', fileName);
if (!Array.isArray(botList)) continue;
for (const bot of botList) {
@ -139,7 +151,9 @@ class BotService {
}
for (const [groupId, botEntries] of groupMap.entries()) {
logger.debug(`[群 ${groupId}] 候选Bot列表: ${JSON.stringify(botEntries)}`);
this.logger.debug(
`[群 ${groupId}] 候选Bot列表: ${JSON.stringify(botEntries)}`,
);
const clientGroups = new Map<string, number[]>();
botEntries.forEach(({ botId, clientId }) => {
@ -147,30 +161,32 @@ class BotService {
clientGroups.get(clientId)!.push(botId);
});
const selectedClientId = tools.getRandomItem([...clientGroups.keys()]);
const selectedClientId = this.tools.getRandomItem([
...clientGroups.keys(),
]);
const botCandidates = clientGroups.get(selectedClientId)!;
const selectedBotId = tools.getRandomItem(botCandidates);
const delay = tools.getRandomDelay(10_000, 150_000);
const selectedBotId = this.tools.getRandomItem(botCandidates);
const delay = this.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);
setTimeout(() => {
const sendData = {
type: 'sendMessage',
data: {
botId: selectedBotId,
groupId,
clientId: selectedClientId,
message,
},
};
this.logger.log(
`[广播] 向群 ${groupId} 使用Bot ${selectedBotId}(客户端 ${selectedClientId})发送消息${message},延迟 ${
delay / 1000
} `,
);
this.wsClientManager.send(selectedClientId, sendData).catch((e) => {
this.logger.error(`发送到群${groupId}失败:`, e);
});
}, delay);
}
}
@ -180,7 +196,7 @@ class BotService {
* @private
*/
private async getBotClient(botId: number): Promise<string | undefined> {
const userPath = paths.get('userData');
const userPath = this.paths.get('userData');
const botsPath = path.join(userPath, '/crystelfBots');
const dirData = await fs.readdir(botsPath);
@ -188,7 +204,7 @@ class BotService {
if (!clientId.endsWith('.json')) continue;
try {
const raw = await redisService.fetch('crystelfBots', clientId);
const raw = await this.redisService.fetch('crystelfBots', clientId);
if (!Array.isArray(raw)) continue;
for (const bot of raw) {
@ -198,7 +214,7 @@ class BotService {
}
}
} catch (err) {
logger.error(`读取${clientId}出错..`);
this.logger.error(`读取${clientId}出错..`);
}
}
return undefined;
@ -210,7 +226,7 @@ class BotService {
* @private
*/
private async getGroupBot(groupId: number): Promise<number | undefined> {
const userPath = paths.get('userData');
const userPath = this.paths.get('userData');
const botsPath = path.join(userPath, '/crystelfBots');
const dirData = await fs.readdir(botsPath);
@ -218,7 +234,7 @@ class BotService {
if (!clientId.endsWith('.json')) continue;
try {
const raw = await redisService.fetch('crystelfBots', clientId);
const raw = await this.redisService.fetch('crystelfBots', clientId);
if (!Array.isArray(raw)) continue;
for (const bot of raw) {
@ -231,11 +247,9 @@ class BotService {
}
}
} catch (err) {
logger.error(`读取${clientId}出错..`);
this.logger.error(`读取${clientId}出错..`);
}
}
return undefined;
}
}
export default new BotService();

View File

@ -0,0 +1,79 @@
import {
Controller,
Get,
Param,
Res,
Logger,
HttpException,
HttpStatus,
Inject,
Req,
} from '@nestjs/common';
import { CdnService } from './cdn.service';
import { Response } from 'express';
import { ApiOperation } from '@nestjs/swagger';
@Controller()
export class CdnController {
private readonly logger = new Logger(CdnController.name);
constructor(@Inject(CdnService) private readonly fileService: CdnService) {}
private async deliverFile(relativePath: string, res: Response) {
try {
this.logger.log(`有个小可爱正在请求 /cdn/${relativePath} ..`);
const filePath = await this.fileService.getFile(relativePath);
if (!filePath) {
this.logger.warn(`${relativePath}:文件不存在..`);
throw new HttpException('文件不存在啦!', HttpStatus.NOT_FOUND);
}
res.sendFile(filePath, (err) => {
if (err) {
this.logger.error(`文件投递失败: ${err.message}`);
throw new HttpException(
'Crystelf-CDN处理文件请求时出错..',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
});
this.logger.log(`成功投递文件: ${filePath}`);
} catch (error) {
this.logger.error('晶灵数据请求处理失败:', error);
throw new HttpException(
'Crystelf-CDN处理文件请求时出错..',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('cdn/*')
@ApiOperation({
summary: '获取资源',
description: '由晶灵资源分发服务器(CDN)提供支持',
})
async getFile(@Res() res: Response, @Req() req: Request) {
const relativePath = req.url.replace('/cdn/', ''); //params.path;
return this.deliverFile(relativePath, res);
}
@Get('public/files/*')
async fromPublicFiles(@Res() res: Response, @Req() req: Request) {
const relativePath = req.url.replace('/public/files/', '');
this.logger.debug(
`请求 /public/files/${relativePath} → 代理到 /cdn/${relativePath}`,
);
return this.deliverFile(relativePath, res);
}
@Get('public/cdn/*')
async fromPublicCdn(@Req() req: Request, @Res() res: Response) {
const relativePath = req.url.replace('/public/cdn/', '');
this.logger.debug(
`请求 /public/cdn/${relativePath} → 代理到 /cdn/${relativePath}`,
);
return this.deliverFile(relativePath, res);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CdnController } from './cdn.controller';
import { CdnService } from './cdn.service';
import { PathModule } from '../../core/path/path.module';
@Module({
imports: [PathModule],
controllers: [CdnController],
providers: [CdnService],
})
export class CdnModule {}

View File

@ -0,0 +1,54 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import { existsSync } from 'fs';
import { PathService } from '../../core/path/path.service';
@Injectable()
export class CdnService {
private readonly logger = new Logger(CdnService.name);
private filePath: string;
@Inject(PathService)
private readonly paths: PathService;
constructor() {
this.logger.log(`晶灵云图数据中心初始化.. 数据存储在: ${this.filePath}`);
}
/**
*
* @param relativePath
*/
async getFile(relativePath: string): Promise<string | null> {
if (!this.filePath) this.filePath = this.paths.get('public');
if (
!this.isValidPath(relativePath) &&
!this.isValidFilename(path.basename(relativePath))
) {
throw new Error('非法路径请求');
}
const filePath = path.join(this.filePath, relativePath);
this.logger.debug(`尝试访问文件路径: ${filePath}`);
return existsSync(filePath) ? filePath : null;
}
/**
*
*/
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) {
this.logger.error(err);
return false;
}
}
private isValidFilename(filename: string): boolean {
return /^[a-zA-Z0-9_\-.]+$/.test(filename);
}
}

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

@ -0,0 +1,53 @@
import { Controller, Post, Inject, UseGuards, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiProperty } from '@nestjs/swagger';
import { SystemWebService } from './systemWeb.service';
import { ToolsService } from '../../core/tools/tools.service';
import { TokenAuthGuard } from '../../core/tools/token-auth.guard';
class WebServerDto {
@ApiProperty({
description: '密钥',
example: '1111',
})
token: string;
}
@ApiTags('System')
@Controller('system')
export class SystemWebController {
constructor(
@Inject(SystemWebService)
private readonly systemService: SystemWebService,
@Inject(ToolsService)
private readonly toolService: ToolsService,
) {}
/**
*
*/
@Post('restart')
@ApiOperation({
summary: '系统重启',
description: '核心执行重启',
})
@UseGuards(TokenAuthGuard)
@ApiBody({ type: WebServerDto })
async systemRestart(@Param('token') token: string): Promise<string> {
this.systemService.systemRestart();
return '核心正在重启..';
}
/**
*
*/
@Post('getRestartTime')
@ApiOperation({
summary: '获取重启所需时间',
description: '返回上次核心重启的耗时',
})
@UseGuards(TokenAuthGuard)
@ApiBody({ type: WebServerDto })
async getRestartTime(@Param('token') token: string): Promise<string> {
return await this.systemService.getRestartTime();
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SystemWebController } from './systemWeb.controller';
import { SystemWebService } from './systemWeb.service';
import { ToolsModule } from '../../core/tools/tools.module';
import { PathModule } from '../../core/path/path.module';
import { SystemModule } from '../../core/system/system.module';
@Module({
imports: [ToolsModule, SystemModule, PathModule],
controllers: [SystemWebController],
providers: [SystemWebService],
})
export class SystemWebModule {}

View File

@ -0,0 +1,34 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import fs from 'fs/promises';
import * as path from 'path';
import { PathService } from '../../core/path/path.service';
import { SystemService } from 'src/core/system/system.service';
@Injectable()
export class SystemWebService {
private readonly logger = new Logger(SystemWebService.name);
@Inject(SystemService)
private readonly system: SystemService;
@Inject(PathService)
private readonly pathService: PathService;
/**
*
*/
async systemRestart(): Promise<void> {
this.logger.debug(`有个小可爱正在请求重启核心..`);
await this.system.restart();
}
/**
*
*/
async getRestartTime(): Promise<string> {
this.logger.debug(`有个小可爱想知道核心重启花了多久..`);
const restartTimePath = path.join(
this.pathService.get('temp'),
'restart_time',
);
return await fs.readFile(restartTimePath, 'utf8');
}
}

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 +1,83 @@
import express from 'express';
import WordsService from './words.service';
import response from '../../utils/core/response';
import tools from '../../utils/modules/tools';
import {
Controller,
Get,
Param,
Post,
HttpException,
HttpStatus,
Logger,
Inject,
UseGuards,
} from '@nestjs/common';
import { WordsService } from './words.service';
import { TokenAuthGuard } from '../../core/tools/token-auth.guard';
import { ApiBody, ApiOperation, ApiProperty } from '@nestjs/swagger';
class WordsController {
private readonly router: express.Router;
class WordsDto {
@ApiProperty({
description: '文案id',
example: 'poke',
})
id: string;
@ApiProperty({
description: '密钥',
example: '1111',
})
token: string;
}
constructor() {
this.router = express.Router();
this.init();
}
@Controller('words')
export class WordsController {
private readonly logger = new Logger(WordsController.name);
public getRouter(): express.Router {
return this.router;
}
private init(): void {
this.router.get('/getText/:id', this.getText);
this.router.post('/reloadText', this.reloadWord);
}
constructor(
@Inject(WordsService) private readonly wordsService: WordsService,
) {}
/**
*
* @param req
* @param res
*/
private getText = async (req: express.Request, res: express.Response): Promise<void> => {
@Get('getText/:id')
@ApiOperation({
summary: '获取随机文案',
})
async getText(@Param('id') id: string) {
try {
const id = req.params.id;
const texts = await WordsService.loadWordById(id.toString());
const texts = await this.wordsService.loadWordById(id);
if (!texts || texts.length === 0) {
return await response.error(res, `文案${id}不存在或为空..`, 404);
throw new HttpException(
`文案 ${id} 不存在或为空..`,
HttpStatus.NOT_FOUND,
);
}
const randomIndex = Math.floor(Math.random() * texts.length);
const result = texts[randomIndex];
await response.success(res, result);
return texts[randomIndex];
} catch (e) {
await response.error(res);
this.logger.error(`getText 失败: ${e?.message}`);
throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR);
}
};
}
/**
*
* @param req
* @param res
*/
private reloadWord = async (req: express.Request, res: express.Response): Promise<void> => {
@Post('reloadText/:id')
@ApiOperation({
summary: '重载某条文案',
})
@UseGuards(TokenAuthGuard)
@ApiBody({ type: WordsDto })
async reloadWord(@Param('id') id: string, @Param('token') token: string) {
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, '重载失败..');
}
const success = await this.wordsService.reloadWord(id);
if (success) {
return '成功重载..';
} else {
await tools.tokenCheckFailed(res, token);
throw new HttpException('重载失败..', HttpStatus.BAD_REQUEST);
}
} catch (e) {
await response.error(res);
this.logger.error(`reloadWord 失败: ${e?.message}`);
throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR);
}
};
}
}
export default new WordsController();

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { WordsController } from './words.controller';
import { WordsService } from './words.service';
import { PathModule } from '../../core/path/path.module';
import { ToolsModule } from '../../core/tools/tools.module';
import { AutoUpdateModule } from '../../core/auto-update/auto-update.module';
@Module({
imports: [PathModule, ToolsModule, AutoUpdateModule],
controllers: [WordsController],
providers: [WordsService],
exports: [WordsService],
})
export class WordsModule {}

View File

@ -1,30 +1,62 @@
import path from 'path';
import paths from '../../utils/core/path';
import fs from 'fs/promises';
import logger from '../../utils/core/logger';
import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs/promises';
import { PathService } from '../../core/path/path.service';
import { AutoUpdateService } from '../../core/auto-update/auto-update.service';
class WordsService {
private wordCache: Record<string, string[]> = {}; //缓存
private readonly clearIntervalMs = 30 * 60 * 1000; //30min
@Injectable()
export class WordsService {
private readonly logger = new Logger(WordsService.name);
private wordCache: Record<string, string[]> = {};
private readonly clearIntervalMs = 30 * 60 * 1000; // 30min
@Inject(PathService)
private readonly paths: PathService;
@Inject(AutoUpdateService)
private readonly autoUpdateService: AutoUpdateService;
constructor() {
this.startAutoClear();
this.startAutoUpdate();
}
/**
*
*/
private startAutoClear() {
setInterval(() => {
logger.info('[WordsService] Clearing wordCache..');
this.logger.log('清理文案缓存..');
this.wordCache = {};
}, this.clearIntervalMs);
}
/**
* json到内存&
* @param id
* words
*/
public async loadWordById(id: string): Promise<string[] | null> {
logger.info(`Loading words ${id}..`);
private startAutoUpdate() {
setInterval(async () => {
const wordsPath = this.paths.get('words');
this.logger.log('定时检查文案仓库更新..');
const updated = await this.autoUpdateService.checkRepoForUpdates(
wordsPath,
'words 仓库',
);
if (updated) {
this.logger.log('文案仓库已更新,清理缓存..');
this.wordCache = {};
}
}, this.clearIntervalMs);
}
/**
*
*/
async loadWordById(id: string): Promise<string[] | null> {
this.logger.log(`加载文案 ${id}..`);
if (this.wordCache[id]) return this.wordCache[id];
const filePath = path.join(paths.get('words'), `${id}.json`);
const filePath = path.join(this.paths.get('words'), `${id}.json`);
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(content);
@ -32,35 +64,31 @@ class WordsService {
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;
} catch (e) {
this.logger.error(`加载文案失败: ${id}..`, e);
return null;
}
}
/**
* json到内存
* @param id
* json
*/
public async reloadWord(id: string): Promise<boolean> {
logger.info(`Reloading word: ${id}..`);
const filePath = path.join(paths.get('words'), `${id}.json`);
async reloadWord(id: string): Promise<boolean> {
this.logger.log(`重载文案: ${id}..`);
const filePath = path.join(this.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;
}
return false;
} catch (e) {
logger.error(`Failed to reloadWordById: ${id}..`);
this.logger.error(`重载文案失败: ${id}`, e);
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,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

@ -0,0 +1,6 @@
import { AuthenticatedSocket } from './ws.interface';
export interface IMessageHandler {
type: string; //消息类型
handle(socket: AuthenticatedSocket, msg: any): Promise<void>;
}

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,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": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}