Compare commits

..

No commits in common. "0f303dfc5c5a372d666aa28ea2f76ea4942fb98a" and "4846e27e6a1b639fd03d3088fd29a8e3276f500f" have entirely different histories.

99 changed files with 2623 additions and 8598 deletions

59
.gitignore vendored
View File

@ -1,56 +1,15 @@
# compiled output /tmp
/dist /out-tsc
/node_modules
/build
# Logs /node_modules
logs
*.log
npm-debug.log* npm-debug.log*
pnpm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* /.pnp
.pnp.js
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env .env
.env.development.local .vscode/*
.env.test.local /dist/
.env.production.local /logs/
.env.local /private/
# 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 Normal file
View File

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

View File

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

14
.idea/crystelf-core.iml generated Normal file
View File

@ -0,0 +1,14 @@
<?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

@ -0,0 +1,6 @@
<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 Normal file
View File

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

2
.idea/vcs.xml generated
View File

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

14
.idea/webResources.xml generated Normal file
View File

@ -0,0 +1,14 @@
<?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,4 +1,7 @@
{ {
"semi": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all" "printWidth": 100,
} "tabWidth": 2,
"trailingComma": "es5"
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
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.

View File

@ -1,34 +0,0 @@
// @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'
},
},
);

View File

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

View File

@ -1,81 +1,33 @@
{ {
"name": "nest-backend", "name": "crystelf-core",
"version": "0.0.1", "version": "1.0.0",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": { "scripts": {
"build": "nest build", "dev": "ts-node-dev src/main.ts",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "node dist/main.js",
"start": "nest start", "build": "tsc"
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1", "axios": "^1.8.4",
"@nestjs/common": "^11.0.1", "chalk": "4",
"@nestjs/config": "^4.0.2", "compression": "^1.8.0",
"@nestjs/core": "^11.0.1", "dotenv": "^16.0.0",
"@nestjs/platform-express": "^11.0.1", "express": "^4.18.0",
"@nestjs/platform-socket.io": "^11.1.6", "ioredis": "^5.6.0",
"@nestjs/platform-ws": "^11.1.6", "mkdirp": "^3.0.1",
"@nestjs/swagger": "^11.2.0", "multer": "1.4.5-lts.2",
"@nestjs/websockets": "^11.1.6", "simple-git": "^3.27.0",
"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", "uuid": "^11.1.0",
"ws": "^8.18.3" "ws": "^8.18.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@types/compression": "^1.7.5",
"@eslint/js": "^9.18.0", "@types/express": "^4.17.0",
"@nestjs/cli": "^11.0.0", "@types/mkdirp": "^2.0.0",
"@nestjs/schematics": "^11.0.0", "@types/multer": "^1.4.12",
"@nestjs/testing": "^11.0.1", "@types/node": "^18.0.0",
"@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", "@types/ws": "^8.18.1",
"eslint": "^9.18.0", "prettier": "^3.5.3",
"eslint-config-prettier": "^10.0.1", "ts-node-dev": "^2.0.0",
"eslint-plugin-prettier": "^5.2.2", "typescript": "^5.0.0"
"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"
} }
} }

6836
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +0,0 @@
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 {}

95
src/app.ts Normal file
View File

@ -0,0 +1,95 @@
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

@ -1,31 +0,0 @@
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

@ -1,29 +0,0 @@
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

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

View File

@ -1,16 +0,0 @@
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

@ -1,52 +0,0 @@
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}`);
}
});
}
}

1
src/config/keep Normal file
View File

@ -0,0 +1 @@
keep

View File

@ -1,10 +0,0 @@
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

@ -1,112 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,33 +0,0 @@
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

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

View File

@ -1,109 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,77 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,185 +0,0 @@
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 +0,0 @@
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

@ -1,68 +0,0 @@
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

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

View File

@ -1,37 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,82 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,18 +0,0 @@
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 +0,0 @@
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);
}
}

View File

@ -1,153 +0,0 @@
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');
}
}
}

View File

@ -1,44 +0,0 @@
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 {}

View File

@ -1,47 +0,0 @@
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,44 +1,31 @@
import { NestFactory } from '@nestjs/core'; import apps from './app';
import { AppModule } from './app.module'; import logger from './utils/core/logger';
import { Logger, RequestMethod } from '@nestjs/common'; import config from './utils/core/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import redis from './services/redis/redis';
import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import autoUpdater from './utils/core/autoUpdater';
import { AllExceptionsFilter } from './common/filters/all-exception.filter'; import System from './utils/core/system';
import { SystemService } from './core/system/system.service';
import { WsAdapter } from '@nestjs/platform-ws';
async function bootstrap() { config.check(['PORT', 'DEBUG', 'RD_PORT', 'RD_ADD', 'WS_SECRET', 'WS_PORT']);
Logger.log('晶灵核心初始化..'); const PORT = config.get('PORT') || 3000;
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api', { apps
exclude: [ .createApp()
'cdn', .then(async (app) => {
{ path: 'cdn/(.*)', method: RequestMethod.ALL }, app.listen(PORT, () => {
'public', logger.info(`Crystelf-core listening on ${PORT}`);
{ path: 'public/(.*)', method: RequestMethod.ALL }, });
], const isUpdated = await autoUpdater.checkForUpdates();
if (isUpdated) {
logger.warn(`检测到更新,正在重启..`);
await System.restart();
}
})
.catch((err) => {
logger.error('Crystelf-core启动失败:', err);
process.exit(1);
}); });
app.useGlobalInterceptors(new ResponseInterceptor());
app.useGlobalFilters(new AllExceptionsFilter()); process.on('SIGTERM', async () => {
const systemService = app.get(SystemService); await redis.disconnect();
const restartDuration = systemService.checkRestartTime(); process.exit(0);
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,70 +1,156 @@
import { Body, Controller, Inject, Post, UseGuards } from '@nestjs/common'; import express from 'express';
import { ApiOperation, ApiTags, ApiBody } from '@nestjs/swagger'; import response from '../../utils/core/response';
import { BotService } from './bot.service'; import BotService from './bot.service';
import { WsClientManager } from 'src/core/ws/ws-client.manager'; import tools from '../../utils/modules/tools';
import { TokenAuthGuard } from 'src/core/tools/token-auth.guard'; import logger from '../../utils/core/logger';
import { import wsClientManager from '../../services/ws/wsClientManager';
BroadcastDto,
GroupInfoDto,
SendMessageDto,
TokenDto,
} from './bot.dto';
@ApiTags('Bot相关操作') class BotController {
@Controller('bot') private readonly router: express.Router;
export class BotController {
constructor(
@Inject(BotService)
private readonly botService: BotService,
@Inject(WsClientManager)
private readonly wsClientManager: WsClientManager,
) {}
@Post('getBotId') constructor() {
@UseGuards(TokenAuthGuard) this.router = express.Router();
@ApiOperation({ summary: '获取当前连接到核心的全部 botId 数组' }) this.init();
async postBotsId(@Body() dto: TokenDto) {
return this.botService.getBotId();
} }
@Post('getGroupInfo') public getRouter(): express.Router {
@UseGuards(TokenAuthGuard) return this.router;
@ApiOperation({ summary: '获取群聊信息' })
@ApiBody({ type: GroupInfoDto })
async postGroupInfo(@Body() dto: GroupInfoDto) {
return this.botService.getGroupInfo({ groupId: dto.groupId });
} }
@Post('reportBots') private init(): void {
@UseGuards(TokenAuthGuard) this.router.post(`/getBotId`, this.postBotsId);
@ApiOperation({ summary: '广播:要求同步群聊信息和 bot 连接情况' }) this.router.post('/getGroupInfo', this.postGroupInfo);
async reportBots(@Body() dto: TokenDto) { this.router.post('/sendMessage', this.sendMessage);
const sendMessage = { this.router.post('/reportBots', this.reportBots);
type: 'reportBots', this.router.post('/broadcast', this.smartBroadcast);
data: {},
};
await this.wsClientManager.broadcast(sendMessage);
return { message: '正在请求同步 bot 数据..' };
} }
@Post('sendMessage') /**
@UseGuards(TokenAuthGuard) * botId数组
@ApiOperation({ summary: '发送消息到群聊', description: '自动选择bot发送' }) * @param req
@ApiBody({ type: SendMessageDto }) * @param res
async sendMessage(@Body() dto: SendMessageDto) { */
const flag = await this.botService.sendMessage(dto.groupId, dto.message); private postBotsId = async (req: express.Request, res: express.Response): Promise<void> => {
if (!flag) { try {
return { message: '消息发送失败' }; 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);
} }
return { message: '消息发送成功' }; };
}
@Post('broadcast') /**
@UseGuards(TokenAuthGuard) *
@ApiOperation({ summary: '广播消息到全部群聊', description: '随机延迟' }) * @example req示例
@ApiBody({ type: BroadcastDto }) * ```json
async smartBroadcast(@Body() dto: BroadcastDto) { * {
await this.botService.broadcastToAllGroups(dto.message); * token: 114514,
return { message: '广播任务已开始,正在后台执行..' }; * groupId: 114514
} * }
* ```
* @param req
* @param res
*/
private postGroupInfo = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const groupId: number = req.body.groupId;
let returnData = await BotService.getGroupInfo({ groupId: groupId });
if (returnData) {
await response.success(res, returnData);
logger.debug(returnData);
} else {
await response.error(res);
}
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
/**
* 广bot连接情况
* @param req
* @param res
*/
// TODO 测试接口可用性
private reportBots = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const sendMessage = {
type: 'reportBots',
data: {},
};
logger.info(`正在请求同步bot数据..`);
await response.success(res, {});
await wsClientManager.broadcast(sendMessage);
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
/**
* ,client
* @param req
* @param res
*/
// TODO 测试接口可用性
private sendMessage = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
if (tools.checkToken(token.toString())) {
const groupId: number = Number(req.body.groupId);
const message: string = req.body.message.toString();
const flag: boolean = await BotService.sendMessage(groupId, message);
if (flag) {
await response.success(res, { message: '消息发送成功..' });
} else {
await response.error(res);
}
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
/**
* 广
* @param req
* @param res
*/
// TODO 测试接口可用性
private smartBroadcast = async (req: express.Request, res: express.Response): Promise<void> => {
try {
const token = req.body.token;
const message = req.body.message;
if (!message || typeof message !== 'string') {
return await response.error(res, '缺少 message 字段', 400);
}
if (tools.checkToken(token.toString())) {
logger.info(`广播任务已开始,正在后台执行..`);
await response.success(res, '广播任务已开始,正在后台执行..');
await BotService.broadcastToAllGroups(message);
} else {
await tools.tokenCheckFailed(res, token);
}
} catch (e) {
await response.error(res);
}
};
} }
export default new BotController();

View File

@ -1,24 +0,0 @@
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

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

View File

@ -1,79 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -0,0 +1,112 @@
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

@ -0,0 +1,93 @@
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

@ -0,0 +1,45 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,62 @@
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

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

View File

@ -1,53 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -0,0 +1,31 @@
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

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

View File

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

View File

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

View File

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

191
src/services/redis/redis.ts Normal file
View File

@ -0,0 +1,191 @@
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

View File

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

100
src/services/ws/wsServer.ts Normal file
View File

@ -0,0 +1,100 @@
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;

70
src/test/wsTestClient.ts Normal file
View File

@ -0,0 +1,70 @@
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

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

16
src/types/qq/userInfo.ts Normal file
View File

@ -0,0 +1,16 @@
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[];
}

6
src/types/retry.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,90 @@
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;

72
src/utils/core/config.ts Normal file
View File

@ -0,0 +1,72 @@
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;

74
src/utils/core/date.ts Normal file
View File

@ -0,0 +1,74 @@
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;

57
src/utils/core/file.ts Normal file
View File

@ -0,0 +1,57 @@
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;

69
src/utils/core/logger.ts Normal file
View File

@ -0,0 +1,69 @@
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;

98
src/utils/core/path.ts Normal file
View File

@ -0,0 +1,98 @@
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

@ -0,0 +1,72 @@
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;

40
src/utils/core/system.ts Normal file
View File

@ -0,0 +1,40 @@
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;

48
src/utils/core/tool.ts Normal file
View File

@ -0,0 +1,48 @@
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;

0
src/utils/index.ts Normal file
View File

View File

@ -0,0 +1,29 @@
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

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

49
src/utils/ws/wsTools.ts Normal file
View File

@ -0,0 +1,49 @@
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;

View File

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

View File

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