mirror of
https://github.com/crystelf/crystelf-core.git
synced 2025-12-05 10:31:56 +00:00
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
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');
|
||
}
|
||
}
|
||
}
|