Compare commits

...

3 Commits

Author SHA1 Message Date
fc2ffeb145 feat:保存调用日志 2025-10-15 15:16:21 +08:00
c2194e6140 feat:优化日志输出 2025-10-15 15:03:03 +08:00
7a5a9edde3 feat:优化错误类 2025-10-15 13:53:45 +08:00
11 changed files with 404 additions and 104 deletions

View File

@ -4,28 +4,62 @@ import {
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
/**
*
*
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
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 request = ctx.getRequest();
const message =
exception instanceof HttpException ? exception.message : '服务器内部错误';
let status: number;
let message: string;
let errorDetails: any = null;
response.status(status).json({
if (exception instanceof HttpException) {
status = exception.getStatus();
message = exception.message;
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
errorDetails = exceptionResponse;
}
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
if (exception instanceof Error) {
message = exception.message || '服务器内部错误';
errorDetails = {
name: exception.name,
stack:
process.env.NODE_ENV === 'development'
? exception.stack
: undefined,
};
} else {
message = '服务器内部错误';
errorDetails = String(exception);
}
}
this.logger.error(
`异常捕获 - ${request.method} ${request.url} - ${status} - ${message}`,
);
const errorResponse = {
success: false,
data: null,
message,
});
...(process.env.NODE_ENV === 'development' &&
errorDetails && { errorDetails }),
};
response.status(status).json(errorResponse);
}
}

View File

@ -0,0 +1,108 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import * as fs from 'fs';
import * as path from 'path';
interface RequestLogEntry {
timestamp: string;
ip: string | string[] | undefined;
method: string;
url: string;
controller: string;
handler: string;
userAgent?: string;
params?: any;
query?: any;
body?: any;
statusCode?: number;
durationMs?: number;
}
@Injectable()
export class RequestLogInterceptor implements NestInterceptor {
private readonly logger = new Logger(RequestLogInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const http = context.switchToHttp();
const req = http.getRequest();
const res = http.getResponse();
const now = Date.now();
const controller = context.getClass().name;
const handler = context.getHandler().name;
const ip = req.headers['x-forwarded-for'] || req.ip;
const method = req.method;
const url = req.originalUrl || req.url;
const userAgent = req.headers['user-agent'];
this.logger.log(
`${method} ${url} - ${controller}.${handler} - ip=${Array.isArray(ip) ? ip[0] : ip}`,
);
return next.handle().pipe(
tap({
next: () => {
this.writeLog({
timestamp: new Date(now).toISOString(),
ip,
method,
url,
controller,
handler,
userAgent,
params: req.params,
query: req.query,
body: req.body,
statusCode: res.statusCode,
durationMs: Date.now() - now,
});
},
error: () => {
this.writeLog({
timestamp: new Date(now).toISOString(),
ip,
method,
url,
controller,
handler,
userAgent,
params: req.params,
query: req.query,
body: req.body,
statusCode: res.statusCode,
durationMs: Date.now() - now,
});
},
}),
);
}
private writeLog(entry: RequestLogEntry) {
try {
const baseDir = path.resolve(process.cwd(), 'logs');
if (!fs.existsSync(baseDir)) {
fs.mkdirSync(baseDir, { recursive: true });
}
const fileName = `access-${this.getDateStr()}.jsonl`;
const filePath = path.join(baseDir, fileName);
const serialized = JSON.stringify(entry) + '\n';
fs.appendFile(filePath, serialized, { encoding: 'utf8' }, () => {});
} catch (err) {
this.logger.warn(`写入访问日志失败: ${err?.message || err}`);
}
}
private getDateStr(): string {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
}

View File

@ -0,0 +1,108 @@
import { HttpException, HttpStatus, Logger } from '@nestjs/common';
/**
*
*/
export class ErrorUtil {
private static readonly logger = new Logger(ErrorUtil.name);
/**
*
* @param message
* @param status HTTP状态码
* @param details
*/
static createBusinessError(
message: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
details?: any,
): HttpException {
return new HttpException(
{
message,
details,
timestamp: new Date().toISOString(),
},
status,
);
}
/**
*
* @param message
* @param originalError
*/
static createInternalError(
message: string,
originalError?: any,
): HttpException {
return new HttpException(
{
message,
originalError: originalError?.message || originalError,
timestamp: new Date().toISOString(),
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
/**
*
* @param resource
* @param identifier
*/
static createNotFoundError(
resource: string,
identifier?: string,
): HttpException {
const message = identifier
? `${resource} '${identifier}' 不存在`
: `${resource} 不存在`;
return this.createBusinessError(message, HttpStatus.NOT_FOUND);
}
/**
*
* @param message
* @param field
*/
static createValidationError(message: string, field?: string): HttpException {
return new HttpException(
{
message,
field,
timestamp: new Date().toISOString(),
},
HttpStatus.BAD_REQUEST,
);
}
/**
* HttpException
* @param error
* @param defaultMessage
* @param context
*/
static handleUnknownError(
error: any,
defaultMessage: string = '服务器内部错误',
context?: string,
): HttpException {
const errorMsg = error?.message || String(error);
const logMessage = context ? `${context}: ${errorMsg}` : errorMsg;
this.logger.error(logMessage);
if (error instanceof HttpException) {
return error;
}
if (error instanceof Error) {
return this.createInternalError(
`${defaultMessage}: ${error.message}`,
error,
);
}
return this.createInternalError(`${defaultMessage}: ${String(error)}`);
}
}

View File

@ -3,6 +3,7 @@ import { AppModule } from './app.module';
import { Logger, RequestMethod } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { RequestLogInterceptor } from './common/interceptors/request-log.interceptor';
import { AllExceptionsFilter } from './common/filters/all-exception.filter';
import { SystemService } from './core/system/system.service';
import { WsAdapter } from '@nestjs/platform-ws';
@ -35,7 +36,7 @@ async function bootstrap() {
{ path: 'public/(.*)', method: RequestMethod.ALL },
],
});
app.useGlobalInterceptors(new ResponseInterceptor());
app.useGlobalInterceptors(new RequestLogInterceptor(), new ResponseInterceptor());
app.useGlobalFilters(new AllExceptionsFilter());
const systemService = app.get(SystemService);
const restartDuration = systemService.checkRestartTime();

View File

@ -1,5 +1,5 @@
import { Body, Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags, ApiBody } from '@nestjs/swagger';
import { ApiOperation, ApiTags, ApiBody, ApiHeader } 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';
@ -23,6 +23,14 @@ export class BotController {
@Post('getBotId')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '获取当前连接到核心的全部 botId 数组' })
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '获取botId请求',
schema: {
type: 'object',
properties: {},
},
})
public async postBotsId(@Body() dto: TokenDto) {
return this.botService.getBotId();
}
@ -30,7 +38,17 @@ export class BotController {
@Post('getGroupInfo')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '获取群聊信息' })
@ApiBody({ type: GroupInfoDto })
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '获取群聊信息参数',
schema: {
type: 'object',
properties: {
groupId: { type: 'number', description: '群号', example: 114514 },
},
required: ['groupId'],
},
})
public async postGroupInfo(@Body() dto: GroupInfoDto) {
return this.botService.getGroupInfo({ groupId: dto.groupId });
}
@ -38,6 +56,14 @@ export class BotController {
@Post('reportBots')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '广播:要求同步群聊信息和 bot 连接情况' })
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '广播同步请求',
schema: {
type: 'object',
properties: {},
},
})
public async reportBots(@Body() dto: TokenDto) {
const sendMessage = {
type: 'reportBots',
@ -50,7 +76,18 @@ export class BotController {
@Post('sendMessage')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '发送消息到群聊', description: '自动选择bot发送' })
@ApiBody({ type: SendMessageDto })
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '发送消息参数',
schema: {
type: 'object',
properties: {
groupId: { type: 'number', description: '群号', example: 114514 },
message: { type: 'string', description: '要发送的消息', example: 'Ciallo(∠・ω< )⌒★' },
},
required: ['groupId', 'message'],
},
})
public async sendMessage(@Body() dto: SendMessageDto) {
const flag = await this.botService.sendMessage(dto.groupId, dto.message);
if (!flag) {
@ -62,7 +99,17 @@ export class BotController {
@Post('broadcast')
@UseGuards(TokenAuthGuard)
@ApiOperation({ summary: '广播消息到全部群聊', description: '随机延迟' })
@ApiBody({ type: BroadcastDto })
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '广播消息参数',
schema: {
type: 'object',
properties: {
message: { type: 'string', description: '要广播的消息', example: '全体目光向我看齐!我宣布个事儿..' },
},
required: ['message'],
},
})
public async smartBroadcast(@Body() dto: BroadcastDto) {
await this.botService.broadcastToAllGroups(dto.message);
return { message: '广播任务已开始,正在后台执行..' };

View File

@ -1,11 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class TokenDto {
@ApiProperty({ description: '访问核心的鉴权 token' })
token: string;
}
export class GroupInfoDto extends TokenDto {
export class GroupInfoDto {
@ApiProperty({ description: '群号', example: 114514 })
groupId: number;
}
@ -15,7 +13,7 @@ export class SendMessageDto extends GroupInfoDto {
message: string;
}
export class BroadcastDto extends TokenDto {
export class BroadcastDto {
@ApiProperty({
description: '要广播的消息',
example: '全体目光向我看齐!我宣布个事儿..',

View File

@ -1,17 +1,8 @@
import {
Controller,
Get,
Param,
Res,
Logger,
HttpException,
HttpStatus,
Inject,
Req,
} from '@nestjs/common';
import { Controller, Get, Res, Logger, Inject, Req } from '@nestjs/common';
import { CdnService } from './cdn.service';
import { Response } from 'express';
import { ApiOperation } from '@nestjs/swagger';
import { ErrorUtil } from '../../common/utils/error.util';
@Controller()
export class CdnController {
@ -26,26 +17,19 @@ export class CdnController {
const filePath = await this.fileService.getFile(relativePath);
if (!filePath) {
this.logger.warn(`${relativePath}:文件不存在..`);
throw new HttpException('文件不存在啦!', HttpStatus.NOT_FOUND);
throw ErrorUtil.createNotFoundError('文件', relativePath);
}
res.sendFile(filePath, (err) => {
if (err) {
this.logger.error(`文件投递失败: ${err.message}`);
throw new HttpException(
'Crystelf-CDN处理文件请求时出错..',
HttpStatus.INTERNAL_SERVER_ERROR,
);
throw ErrorUtil.createInternalError('文件投递失败', err);
}
});
this.logger.log(`成功投递文件: ${filePath}`);
} catch (error) {
this.logger.error('晶灵数据请求处理失败:', error);
throw new HttpException(
'Crystelf-CDN处理文件请求时出错..',
HttpStatus.INTERNAL_SERVER_ERROR,
);
throw ErrorUtil.handleUnknownError(error, 'CDN处理文件请求失败', 'deliverFile');
}
}

View File

@ -5,7 +5,6 @@ import {
Body,
Query,
Res,
HttpException,
HttpStatus,
Logger,
Inject,
@ -35,6 +34,7 @@ import { OpenListService } from '../../core/openlist/openlist.service';
import { PathService } from '../../core/path/path.service';
import { TokenAuthGuard } from '../../core/tools/token-auth.guard';
import { AppConfigService } from '../../config/config.service';
import { ErrorUtil } from '../../common/utils/error.util';
class MemeRequestDto {
character?: string;
@ -64,10 +64,17 @@ export class MemeController {
@Post('get')
@ApiOperation({ summary: '获取随机表情包' })
@ApiQuery({ name: 'character', required: false, description: '角色名称' })
@ApiQuery({ name: 'status', required: false, description: '状态' })
@ApiQuery({ name: 'token', required: false, description: '可选访问令牌' })
@ApiBody({ type: MemeRequestDto })
@ApiHeader({ name: 'x-token', description: '身份验证token', required: false })
@ApiBody({
description: '获取表情包参数',
schema: {
type: 'object',
properties: {
character: { type: 'string', description: '角色名称' },
status: { type: 'string', description: '状态' },
},
},
})
public async getRandomMemePost(
@Body() dto: MemeRequestDto,
@Res() res: Response,
@ -114,10 +121,7 @@ export class MemeController {
);
if (!memePath) {
throw new HttpException(
'没有找到符合条件的表情包',
HttpStatus.NOT_FOUND,
);
throw ErrorUtil.createNotFoundError('表情包');
}
const ext = memePath.split('.').pop()?.toLowerCase();
@ -129,11 +133,8 @@ export class MemeController {
res.setHeader('Content-Type', contentType);
const stream = fs.createReadStream(memePath);
stream.on('error', () => {
throw new HttpException(
'读取表情包失败',
HttpStatus.INTERNAL_SERVER_ERROR,
);
stream.on('error', (error) => {
throw ErrorUtil.createInternalError('读取表情包失败', error);
});
const fd = await fs.promises.open(memePath, 'r');
@ -176,8 +177,7 @@ export class MemeController {
stream.pipe(throttle).pipe(res);
}
} catch (e) {
this.logger.error(`获取表情包失败:${e.message}`);
throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR);
throw ErrorUtil.handleUnknownError(e, '获取表情包失败', 'handleMemeRequest');
}
}
@ -186,7 +186,6 @@ export class MemeController {
* @param file
* @param character
* @param status
* @param res
*/
@Post('upload')
@ApiOperation({ summary: '上传表情包并同步' })
@ -211,7 +210,7 @@ export class MemeController {
@Body('status') status: string,
) {
if (!file) {
throw new HttpException('未检测到上传文件', HttpStatus.BAD_REQUEST);
throw ErrorUtil.createValidationError('未检测到上传文件');
}
try {
@ -226,7 +225,7 @@ export class MemeController {
const buffer = file.buffer || (await fsp.readFile(file.path));
const imgType = await imageType(buffer);
if (!imgType || !['jpg', 'png', 'gif', 'webp'].includes(imgType.ext)) {
throw new HttpException(
throw ErrorUtil.createBusinessError(
'不支持的图片格式',
HttpStatus.UNSUPPORTED_MEDIA_TYPE,
);
@ -243,10 +242,10 @@ export class MemeController {
) {
fileList = listResult.data.content.map((f) => f.name);
} else {
this.logger.warn(`目录为空或返回结构异常${remoteDir}`);
this.logger.warn(`目录为空或返回结构异常:${remoteDir}`);
}
} catch (err) {
this.logger.warn(`获取远程目录失败(${remoteDir})将自动创建`);
this.logger.warn(`获取远程目录失败(${remoteDir}),将自动创建`);
}
const usedNumbers = fileList
@ -271,11 +270,7 @@ export class MemeController {
this.logger.log(`表情包上传成功: ${remoteFilePath}`);
return '表情上传成功..';
} catch (error) {
this.logger.error('表情包上传失败:', error);
throw new HttpException(
`上传失败: ${error.message || error}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
throw ErrorUtil.handleUnknownError(error, '上传失败', 'uploadMeme');
}
}
}

View File

@ -1,16 +1,8 @@
import { Controller, Post, Inject, UseGuards, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiProperty } from '@nestjs/swagger';
import { Controller, Post, Inject, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiHeader } from '@nestjs/swagger';
import { SystemWebService } from './systemWeb.service';
import { TokenAuthGuard } from '../../core/tools/token-auth.guard';
class WebServerDto {
@ApiProperty({
description: '密钥',
example: '1111',
})
token: string;
}
@ApiTags('System')
@Controller('system')
export class SystemWebController {
@ -28,8 +20,15 @@ export class SystemWebController {
description: '核心执行重启',
})
@UseGuards(TokenAuthGuard)
@ApiBody({ type: WebServerDto })
public async systemRestart(@Param('token') token: string): Promise<string> {
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '系统重启请求',
schema: {
type: 'object',
properties: {},
},
})
public async systemRestart(): Promise<string> {
this.systemService.systemRestart();
return '核心正在重启..';
}
@ -43,8 +42,15 @@ export class SystemWebController {
description: '返回上次核心重启的耗时',
})
@UseGuards(TokenAuthGuard)
@ApiBody({ type: WebServerDto })
public async getRestartTime(@Param('token') token: string): Promise<string> {
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '获取重启时间请求',
schema: {
type: 'object',
properties: {},
},
})
public async getRestartTime(): Promise<string> {
return await this.systemService.getRestartTime();
}
}

View File

@ -1,7 +1,6 @@
import {
Controller,
Post,
HttpException,
HttpStatus,
Logger,
Inject,
@ -10,7 +9,8 @@ import {
} from '@nestjs/common';
import { WordsService } from './words.service';
import { TokenAuthGuard } from '../../core/tools/token-auth.guard';
import { ApiBody, ApiOperation, ApiProperty } from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiProperty, ApiHeader } from '@nestjs/swagger';
import { ErrorUtil } from '../../common/utils/error.util';
class WordsDto {
@ApiProperty({ description: '文案类型', example: 'poke' })
@ -27,10 +27,7 @@ class WordsDto {
name?: string;
}
class WordsReloadDto extends WordsDto {
@ApiProperty({ description: '密钥', example: '1111' })
token: string;
}
class WordsReloadDto extends WordsDto {}
@Controller('words')
export class WordsController {
@ -45,15 +42,27 @@ export class WordsController {
*/
@Post('getText')
@ApiOperation({ summary: '获取随机文案' })
@ApiBody({ type: WordsDto })
@ApiBody({
description: '获取文案参数',
schema: {
type: 'object',
properties: {
type: { type: 'string', description: '文案类型', example: 'poke' },
id: { type: 'string', description: '文案名称', example: 'poke' },
name: {
type: 'string',
description: '可选参数:替换文案中的人名',
example: '坤坤',
},
},
required: ['type', 'id'],
},
})
public async getText(@Body() dto: WordsDto) {
try {
const texts = await this.wordsService.loadWord(dto.type, dto.id);
if (!texts || texts.length === 0) {
throw new HttpException(
`文案 ${dto.type}/${dto.id} 不存在或为空..`,
HttpStatus.NOT_FOUND,
);
throw ErrorUtil.createNotFoundError('文案', `${dto.type}/${dto.id}`);
}
const randomIndex = Math.floor(Math.random() * texts.length);
@ -64,8 +73,7 @@ export class WordsController {
return text;
} catch (e) {
this.logger.error(`getText 失败: ${e}`);
throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR);
throw ErrorUtil.handleUnknownError(e, '获取文案失败', 'getText');
}
}
@ -75,18 +83,33 @@ export class WordsController {
@Post('reloadText')
@ApiOperation({ summary: '重载某条文案' })
@UseGuards(TokenAuthGuard)
@ApiBody({ type: WordsReloadDto })
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@ApiBody({
description: '重载文案参数',
schema: {
type: 'object',
properties: {
type: { type: 'string', description: '文案类型', example: 'poke' },
id: { type: 'string', description: '文案名称', example: 'poke' },
name: {
type: 'string',
description: '可选参数:替换文案中的人名',
example: '坤坤',
},
},
required: ['type', 'id'],
},
})
public async reloadWord(@Body() dto: WordsReloadDto) {
try {
const success = await this.wordsService.reloadWord(dto.type, dto.id);
if (success) {
return '成功重载..';
} else {
throw new HttpException('重载失败..', HttpStatus.BAD_REQUEST);
throw ErrorUtil.createBusinessError('重载失败', HttpStatus.BAD_REQUEST);
}
} catch (e) {
this.logger.error(`reloadWord 失败: ${e}`);
throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR);
throw ErrorUtil.handleUnknownError(e, '重载文案失败', 'reloadWord');
}
}
@ -105,15 +128,11 @@ export class WordsController {
try {
const names = await this.wordsService.listWordNames(type);
if (names.length === 0) {
throw new HttpException(
`类型 ${type} 下没有可用文案或目录不存在..`,
HttpStatus.NOT_FOUND,
);
throw ErrorUtil.createNotFoundError('文案类型', type);
}
return names;
} catch (e) {
this.logger.error(`listWords 失败: ${e}`);
throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR);
throw ErrorUtil.handleUnknownError(e, '获取文案列表失败', 'listWords');
}
}
}

View File

@ -101,7 +101,7 @@ export class WordsService {
const safeType = this.safePathSegment(type);
const safeName = this.safePathSegment(name);
const cacheKey = `${safeType}/${safeName}`;
this.logger.log(`重载文案: ${cacheKey}..`);
//this.logger.log(`重载文案: ${cacheKey}..`);
const filePath = path.join(
this.paths.get('words'),
safeType,
@ -136,7 +136,7 @@ export class WordsService {
const names = files
.filter((f) => f.isFile() && f.name.endsWith('.json'))
.map((f) => f.name.replace(/\.json$/, ''));
this.logger.log(`扫描文案类型 ${safeType} 下的文件: ${names.join(', ')}`);
//this.logger.log(`扫描文案类型 ${safeType} 下的文件: ${names.join(', ')}`);
return names;
} catch (e) {
this.logger.error(`读取文案目录失败: ${safeType}`, e);