crystelf-core/src/modules/meme/meme.controller.ts

357 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Controller,
Post,
Get,
Body,
Query,
Res,
HttpStatus,
Logger,
Inject,
Ip,
UseInterceptors,
UploadedFile,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBody,
ApiQuery,
ApiConsumes,
ApiHeader,
} from '@nestjs/swagger';
import { MemeService } from './meme.service';
import { Response } from 'express';
import * as fs from 'fs';
import { Throttle } from 'stream-throttle';
import { ToolsService } from '../../core/tools/tools.service';
import { RedisService } from '../../core/redis/redis.service';
import imageType from 'image-type';
import { FileInterceptor } from '@nestjs/platform-express';
import * as path from 'path';
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;
status?: string;
token?: string;
}
@Controller('meme')
@ApiTags('Meme')
export class MemeController {
private readonly logger = new Logger(MemeController.name);
private static readonly activeConnections = new Map<string, number>();
private static readonly maxConnectionsPerIp = 3; // 每IP最大并发连接数
constructor(
@Inject(MemeService)
private readonly memeService: MemeService,
@Inject(ToolsService)
private readonly toolsService: ToolsService,
@Inject(RedisService)
private readonly redisService: RedisService,
@Inject(OpenListService)
private readonly openListService: OpenListService,
@Inject(PathService)
private readonly pathService: PathService,
@Inject(AppConfigService)
private readonly configService: AppConfigService,
) {}
@Post('get')
@ApiOperation({ summary: '获取随机表情包' })
@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,
@Ip() ip: string,
) {
return this.handleMemeRequest(dto, res, ip, 'POST');
}
@Get()
@ApiOperation({ summary: '获取随机表情包' })
@ApiQuery({ name: 'character', required: false, description: '角色名称' })
@ApiQuery({ name: 'status', required: false, description: '状态' })
@ApiQuery({ name: 'token', required: false, description: '可选访问令牌' })
public async getRandomMemeGet(
@Query() query: MemeRequestDto,
@Res() res: Response,
@Ip() ip: string,
) {
return this.handleMemeRequest(query, res, ip, 'GET');
}
/**
* 增加IP活跃连接数
* @param ip IP地址
* @returns 是否成功增加
*/
private incrementActiveConnections(ip: string): boolean {
const current = MemeController.activeConnections.get(ip) || 0;
if (current >= MemeController.maxConnectionsPerIp) {
return false;
}
MemeController.activeConnections.set(ip, current + 1);
return true;
}
/**
* 减少IP活跃连接数
* @param ip IP地址
*/
private decrementActiveConnections(ip: string): void {
const current = MemeController.activeConnections.get(ip) || 0;
if (current <= 1) {
MemeController.activeConnections.delete(ip);
} else {
MemeController.activeConnections.set(ip, current - 1);
}
}
/**
* 处理请求
* @param dto
* @param res
* @param ip
* @param method
* @private
*/
private async handleMemeRequest(
dto: MemeRequestDto,
res: Response,
ip: string,
method: string,
) {
try {
if (!this.incrementActiveConnections(ip)) {
this.logger.warn(`[${method}] ${ip} 并发连接数超限`);
res.status(429).json({
success: false,
message: '请求过于频繁,请稍后再试',
});
return;
}
const realToken = dto.token;
const hasValidToken =
realToken && this.toolsService.checkToken(realToken);
const memePath = await this.memeService.getRandomMemePath(
dto.character,
dto.status,
);
if (!memePath) {
this.decrementActiveConnections(ip);
throw ErrorUtil.createNotFoundError('表情包');
}
const ext = memePath.split('.').pop()?.toLowerCase();
let contentType = 'image/jpeg';
if (ext === 'png') contentType = 'image/png';
if (ext === 'gif') contentType = 'image/gif';
if (ext === 'webp') contentType = 'image/webp';
res.setHeader('Content-Type', contentType);
const stream = fs.createReadStream(memePath);
stream.on('error', (error) => {
throw ErrorUtil.createInternalError('读取表情包失败', error);
});
const fd = await fs.promises.open(memePath, 'r');
const { buffer } = await fd.read(Buffer.alloc(4100), 0, 4100, 0);
await fd.close();
const type = await imageType(buffer);
const isAnimatedImage =
type?.mime === 'image/gif' ||
type?.mime === 'image/webp' ||
type?.mime === 'image/apng';
const singleRate = 400 * 1024; // 400 KB/s
const maxThreads = 2;
const maxRate = singleRate * maxThreads; // 800 KB/s 每IP
const trafficWindow = 60; // 流量统计窗口60秒
const cleanup = () => {
this.decrementActiveConnections(ip);
};
if (hasValidToken) {
this.logger.log(`[${method}] 有token的入不限速 => ${memePath}`);
stream.pipe(res);
stream.on('end', cleanup);
stream.on('error', cleanup);
} else {
let totalBytes = 0;
stream.on('data', (chunk) => {
totalBytes += chunk.length;
});
stream.on('end', async () => {
cleanup();
try {
await this.redisService.incrementIpTraffic(
ip,
totalBytes,
trafficWindow,
);
} catch (error) {
this.logger.error(`更新流量统计失败: ${error.message}`);
}
});
stream.on('error', (error) => {
cleanup();
this.logger.error(`流传输错误: ${error.message}`);
});
try {
const currentTraffic = await this.redisService.getIpTraffic(ip);
if (currentTraffic > maxRate && !isAnimatedImage) {
this.logger.warn(
`[${method}] ${ip} 流量超限 (${currentTraffic} > ${maxRate}), 拒绝请求`,
);
stream.destroy();
this.decrementActiveConnections(ip);
res.status(429).json({
success: false,
message: '请求过于频繁,请稍后再试',
});
return;
}
} catch (error) {
this.logger.error(`检查流量失败: ${error.message}`);
}
const throttle = new Throttle({ rate: singleRate });
this.logger.log(
`[${method}] 白嫖入限速! (${ip}) => ${memePath} (${isAnimatedImage ? '动态图片不限速' : '静态图片限速'})`,
);
if (isAnimatedImage) {
stream.pipe(res);
} else {
stream.pipe(throttle).pipe(res);
}
}
} catch (e) {
this.decrementActiveConnections(ip);
throw ErrorUtil.handleUnknownError(
e,
'获取表情包失败',
'handleMemeRequest',
);
}
}
/**
* 上传文件
* @param file
* @param character
* @param status
*/
@Post('upload')
@ApiOperation({ summary: '上传表情包并同步' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
@ApiHeader({ name: 'x-token', description: '身份验证token', required: true })
@UseGuards(TokenAuthGuard)
@ApiBody({
description: '上传表情包文件',
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
character: { type: 'string', description: '角色名称' },
status: { type: 'string', description: '状态' },
},
},
})
public async uploadMeme(
@UploadedFile() file: Express.Multer.File,
@Body('character') character: string,
@Body('status') status: string,
) {
if (!file) {
throw ErrorUtil.createValidationError('未检测到上传文件');
}
try {
const fsp = fs.promises;
const safeCharacter = character?.trim() || 'unknown';
const safeStatus = status?.trim() || 'default';
const memeBasePath = this.pathService.get('meme');
const localDir = path.join(memeBasePath, safeCharacter, safeStatus);
await fsp.mkdir(localDir, { recursive: true });
const buffer = file.buffer || (await fsp.readFile(file.path));
const imgType = await imageType(buffer);
if (!imgType || !['jpg', 'png', 'gif', 'webp'].includes(imgType.ext)) {
throw ErrorUtil.createBusinessError(
'不支持的图片格式',
HttpStatus.UNSUPPORTED_MEDIA_TYPE,
);
}
const remoteMemePath = this.configService.get('OPENLIST_API_MEME_PATH');
const remoteDir = `${remoteMemePath}/${safeCharacter}/${safeStatus}/`;
let fileList: string[] = [];
try {
const listResult = await this.openListService.listFiles(remoteDir);
if (
listResult?.code === 200 &&
Array.isArray(listResult.data?.content)
) {
fileList = listResult.data.content.map((f) => f.name);
} else {
this.logger.warn(`目录为空或返回结构异常:${remoteDir}`);
}
} catch (err) {
this.logger.warn(`获取远程目录失败(${remoteDir}),将自动创建`);
}
const usedNumbers = fileList
.map((name) => {
const match = name.match(/^(\d+)\./);
return match ? parseInt(match[1], 10) : null;
})
.filter((n) => n !== null) as number[];
const nextNumber =
usedNumbers.length > 0 ? Math.max(...usedNumbers) + 1 : 1;
const remoteFilename = `${nextNumber}.${imgType.ext}`;
const remoteFilePath = `${remoteDir}${remoteFilename}`;
const localFilePath = path.join(localDir, remoteFilename);
await fsp.writeFile(localFilePath, buffer);
const fileStream = fs.createReadStream(localFilePath);
await this.openListService.uploadFile(
localFilePath,
fileStream,
remoteFilePath,
);
this.logger.log(`表情包上传成功: ${remoteFilePath}`);
return '表情上传成功..';
} catch (error) {
throw ErrorUtil.handleUnknownError(error, '上传失败', 'uploadMeme');
}
}
}