曲线救国(不是)

This commit is contained in:
Jerry 2025-08-25 21:45:03 +08:00
parent 35eea17a8f
commit 6809d07bcf
13 changed files with 566 additions and 28 deletions

View File

@ -9,6 +9,8 @@ import { PersistenceModule } from './core/persistence/persistence.module';
import { RedisModule } from './core/redis/redis.module'; import { RedisModule } from './core/redis/redis.module';
import { WsModule } from './core/ws/ws.module'; import { WsModule } from './core/ws/ws.module';
import { SystemWebModule } from './modules/system/systemWeb.module'; import { SystemWebModule } from './modules/system/systemWeb.module';
import { BotModule } from './modules/bot/bot.module';
import { CdnModule } from './modules/cdn/cdn.module';
@Module({ @Module({
imports: [ imports: [
@ -22,6 +24,8 @@ import { SystemWebModule } from './modules/system/systemWeb.module';
RedisModule, RedisModule,
WsModule, WsModule,
SystemWebModule, SystemWebModule,
BotModule,
CdnModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -21,16 +21,12 @@ export class PathService {
const mappings: Record<PathType, string> = { const mappings: Record<PathType, string> = {
root: this.baseDir, root: this.baseDir,
public: path.join(this.baseDir, 'public'), public: path.join(this.baseDir, 'public'),
images: path.join(this.baseDir, 'public/files/image'),
log: path.join(this.baseDir, 'logs'), log: path.join(this.baseDir, 'logs'),
config: path.join(this.baseDir, 'config'), config: path.join(this.baseDir, 'config'),
temp: path.join(this.baseDir, 'temp'), temp: path.join(this.baseDir, 'temp'),
userData: path.join(this.baseDir, 'private/data'), 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'), package: path.join(this.baseDir, 'package.json'),
modules: path.join(this.baseDir, 'src/modules'), modules: path.join(this.baseDir, 'src/modules'),
uploads: path.join(this.baseDir, 'public/files/uploads'),
words: path.join(this.baseDir, 'private/data/word'), words: path.join(this.baseDir, 'private/data/word'),
}; };
@ -46,9 +42,8 @@ export class PathService {
this.get('log'), this.get('log'),
this.get('config'), this.get('config'),
this.get('userData'), this.get('userData'),
this.get('media'),
this.get('temp'), this.get('temp'),
this.get('uploads'), this.get('public'),
this.get('words'), this.get('words'),
]; ];
@ -105,14 +100,10 @@ export class PathService {
export type PathType = export type PathType =
| 'root' | 'root'
| 'public' | 'public'
| 'images'
| 'log' | 'log'
| 'config' | 'config'
| 'temp' | 'temp'
| 'userData' | 'userData'
| 'files'
| 'package' | 'package'
| 'media'
| 'modules' | 'modules'
| 'words' | 'words';
| 'uploads';

View File

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

View File

@ -1,6 +1,6 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { Logger } from '@nestjs/common'; import { Logger, RequestMethod } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { AllExceptionsFilter } from './common/filters/all-exception.filter'; import { AllExceptionsFilter } from './common/filters/all-exception.filter';
@ -24,7 +24,7 @@ async function bootstrap() {
.setVersion('1.0') .setVersion('1.0')
.build(); .build();
const document = () => SwaggerModule.createDocument(app, config); const document = () => SwaggerModule.createDocument(app, config);
SwaggerModule.setup('', app, document); SwaggerModule.setup('docs', app, document);
app.useWebSocketAdapter(new WsAdapter(app)); app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(7000); await app.listen(7000);
await systemService.checkUpdate().catch((err) => { await systemService.checkUpdate().catch((err) => {
@ -32,6 +32,6 @@ async function bootstrap() {
}); });
} }
bootstrap().then(() => { bootstrap().then(() => {
Logger.log(`API服务已启动http://localhost:7000`); Logger.log(`API服务已启动http://localhost:7000/api`);
Logger.log(`API文档 http://localhost:7000/api`); Logger.log(`API文档 http://localhost:7000/docs`);
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,12 +4,15 @@ import {
Body, Body,
UnauthorizedException, UnauthorizedException,
Inject, Inject,
UseGuards,
Param,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiProperty } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBody, ApiProperty } from '@nestjs/swagger';
import { SystemWebService } from './systemWeb.service'; import { SystemWebService } from './systemWeb.service';
import { ToolsService } from '../../core/tools/tools.service'; import { ToolsService } from '../../core/tools/tools.service';
import { TokenAuthGuard } from '../../core/tools/token-auth.guard';
class TokenDto { class WebServerDto {
@ApiProperty({ @ApiProperty({
description: '密钥', description: '密钥',
example: '1111', example: '1111',
@ -35,12 +38,10 @@ export class SystemWebController {
summary: '系统重启', summary: '系统重启',
description: '核心执行重启', description: '核心执行重启',
}) })
@ApiBody({ type: TokenDto }) @UseGuards(TokenAuthGuard)
async systemRestart(@Body() body: TokenDto): Promise<string> { @ApiBody({ type: WebServerDto })
if (!this.toolService.checkToken(body.token)) { async systemRestart(@Param('token') token: string): Promise<string> {
throw new UnauthorizedException('Token 无效'); this.systemService.systemRestart();
}
await this.systemService.systemRestart();
return '核心正在重启..'; return '核心正在重启..';
} }
@ -52,11 +53,9 @@ export class SystemWebController {
summary: '获取重启所需时间', summary: '获取重启所需时间',
description: '返回上次核心重启的耗时', description: '返回上次核心重启的耗时',
}) })
@ApiBody({ type: TokenDto }) @UseGuards(TokenAuthGuard)
async getRestartTime(@Body() body: TokenDto): Promise<string> { @ApiBody({ type: WebServerDto })
if (!this.toolService.checkToken(body.token)) { async getRestartTime(@Param('token') token: string): Promise<string> {
throw new UnauthorizedException('Token 无效');
}
return await this.systemService.getRestartTime(); return await this.systemService.getRestartTime();
} }
} }

View File

@ -1,6 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import * as path from 'path';
import { PathService } from '../../core/path/path.service'; import { PathService } from '../../core/path/path.service';
import { SystemService } from 'src/core/system/system.service'; import { SystemService } from 'src/core/system/system.service';