Compare commits

...

3 Commits

Author SHA1 Message Date
ccda3ec271 添加部分服务 2025-08-24 22:47:47 +08:00
120bf912f1 犹化ws模块 2025-08-24 21:39:26 +08:00
3b546e50a1 ws模块 2025-08-24 17:41:43 +08:00
25 changed files with 5357 additions and 3619 deletions

View File

@ -20,13 +20,17 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.6",
"@nestjs/platform-ws": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"axios": "^1.10.0",
"@nestjs/websockets": "^11.1.6",
"axios": "1.11.0",
"ioredis": "^5.6.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"simple-git": "^3.28.0",
"ssh2": "^1.16.0",
"uuid": "^11.1.0",
"ws": "^8.18.3"
},
"devDependencies": {

8089
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ 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';
@Module({
imports: [
@ -18,6 +19,7 @@ import { RedisModule } from './core/redis/redis.module';
PersistenceModule,
AutoUpdateModule,
RedisModule,
WsModule,
],
})
export class AppModule {}

View File

@ -22,7 +22,7 @@ export class ResponseInterceptor<T>
map((data) => ({
success: true,
data,
message: '操作成功',
message: '欢迎使用晶灵核心 | Welcome to use crystelf-core',
})),
);
}

View File

@ -19,7 +19,7 @@ export class AppConfigService implements OnModuleInit {
* @param key
* @param defaultValue
*/
get<T = string>(key: string, defaultValue?: T): T | undefined {
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) {

View File

@ -19,6 +19,11 @@ export class PersistenceService {
private readonly fileService: FilesService,
) {}
/**
*
* @param dataName /
* @private
*/
private async ensureDataPath(dataName: string): Promise<void> {
const dataPath = path.join(this.paths.get('userData'), dataName);
try {

View File

@ -2,7 +2,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs';
import { PathService } from '../path/path.service';
import { AutoUpdateModule } from '../auto-update/auto-update.module';
import { AutoUpdateService } from '../auto-update/auto-update.service';
import * as process from 'node:process';

View File

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

View File

@ -1,10 +1,19 @@
import { Injectable, Logger } from '@nestjs/common';
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
@ -48,4 +57,26 @@ export class ToolsService {
getRandomDelay(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* token
* @param token token
*/
checkToken(token: string): boolean {
const expected = this.config.get<string>('TOKEN');
if (!expected) {
this.logger.error('环境变量 TOKEN 未配置,无法进行验证!');
throw new UnauthorizedException('系统配置错误,缺少 TOKEN');
}
return token === expected;
}
/**
* token
* @param token token
*/
tokenCheckFailed(token: string): never {
this.logger.warn(`有个小可爱使用了错误的 token: ${JSON.stringify(token)}`);
throw new UnauthorizedException('token 验证失败..');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

@ -5,6 +5,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { AllExceptionsFilter } from './common/filters/all-exception.filter';
import { SystemService } from './core/system/system.service';
import { WsAdapter } from '@nestjs/platform-ws';
async function bootstrap() {
Logger.log('晶灵核心初始化..');
@ -24,6 +25,7 @@ async function bootstrap() {
.build();
const document = () => SwaggerModule.createDocument(app, config);
SwaggerModule.setup('', app, document);
app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(7000);
await systemService.checkUpdate().catch((err) => {
Logger.error(`自动更新失败: ${err?.message}`, '', 'System');

View File

@ -0,0 +1,58 @@
import {
Controller,
Post,
Body,
UnauthorizedException,
Inject,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
import { SystemWebService } from './system.service';
import { ToolsService } from '../../core/tools/tools.service';
class TokenDto {
token: string;
}
@ApiTags('System')
@Controller('system')
export class SystemController {
constructor(
@Inject(SystemWebService)
private readonly systemService: SystemWebService,
@Inject(ToolsService)
private readonly toolService: ToolsService,
) {}
/**
*
*/
@Post('restart')
@ApiOperation({
summary: '系统重启',
description: '传入正确的 token 后,核心将执行重启。',
})
@ApiBody({ type: TokenDto })
async systemRestart(@Body() body: TokenDto): Promise<string> {
if (!this.toolService.checkToken(body.token)) {
throw new UnauthorizedException('Token 无效');
}
await this.systemService.systemRestart();
return '核心正在重启..';
}
/**
*
*/
@Post('getRestartTime')
@ApiOperation({
summary: '获取重启所需时间',
description: '传入正确的 token返回上次核心重启的耗时',
})
@ApiBody({ type: TokenDto })
async getRestartTime(@Body() body: TokenDto): Promise<string> {
if (!this.toolService.checkToken(body.token)) {
throw new UnauthorizedException('Token 无效');
}
return await this.systemService.getRestartTime();
}
}

View File

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

View File

@ -0,0 +1,34 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import fs from 'fs/promises';
import 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,6 @@
import { AuthenticatedSocket } from './ws.interface';
export interface IMessageHandler {
type: string; //消息类型
handle(socket: AuthenticatedSocket, msg: any): Promise<void>;
}