import { Controller, Post, Get, Body, Query, Res, HttpException, 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'; class MemeRequestDto { character?: string; status?: string; token?: string; } @Controller('meme') @ApiTags('Meme') export class MemeController { private readonly logger = new Logger(MemeController.name); 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: '获取随机表情包' }) @ApiQuery({ name: 'character', required: false, description: '角色名称' }) @ApiQuery({ name: 'status', required: false, description: '状态' }) @ApiQuery({ name: 'token', required: false, description: '可选访问令牌' }) @ApiBody({ type: MemeRequestDto }) 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'); } /** * 处理请求 * @param dto * @param res * @param ip * @param method * @private */ private async handleMemeRequest( dto: MemeRequestDto, res: Response, ip: string, method: string, ) { try { const realToken = dto.token; const hasValidToken = realToken && this.toolsService.checkToken(realToken); const memePath = await this.memeService.getRandomMemePath( dto.character, dto.status, ); if (!memePath) { throw new HttpException( '没有找到符合条件的表情包', HttpStatus.NOT_FOUND, ); } 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', () => { throw new HttpException( '读取表情包失败', HttpStatus.INTERNAL_SERVER_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'; //this.logger.debug(type?.mime); const singleRate = 200 * 1024; // 100 KB/s * 3 const maxThreads = 2; const maxRate = singleRate * maxThreads; if (hasValidToken) { this.logger.log(`[${method}] 有token的入不限速 => ${memePath}`); stream.pipe(res); } else { stream.on('data', async (chunk) => { const bytes = chunk.length; const total = await this.redisService.incrementIpTraffic( ip, bytes, 1, ); if (total > maxRate && !isAnimatedImage) { this.logger.warn(`[${method}] ${ip} 超过速率限制,断开连接..`); stream.destroy(); res.end(); } }); const throttle = new Throttle({ rate: singleRate }); this.logger.log( `[${method}] 白嫖入限速! (${ip}) => ${memePath} `, ); stream.pipe(throttle).pipe(res); } } catch (e) { this.logger.error(`获取表情包失败:${e.message}`); throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR); } } /** * 上传文件 * @param file * @param character * @param status * @param token * @param res */ @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, @Body('token') token: string, @Res() res: Response, ) { if (!file) { throw new HttpException('未检测到上传文件', HttpStatus.BAD_REQUEST); } try { const buffer = file.buffer; const imgType = await imageType(buffer); if (!imgType || !['jpg', 'png', 'gif', 'webp'].includes(imgType.ext)) { throw new HttpException( '不支持的图片格式', HttpStatus.UNSUPPORTED_MEDIA_TYPE, ); } const fsp = fs.promises; const safeCharacter = character?.trim() || 'unknown'; const safeStatus = status?.trim() || 'default'; const tempDir = path.join(this.pathService.get('temp'), 'meme'); await fsp.mkdir(tempDir, { recursive: true }); 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 filename = `${nextNumber}.${imgType.ext}`; const tempFilePath = path.join(tempDir, filename); await fsp.writeFile(tempFilePath, buffer); //const openlistBasePath = this.configService.get('OPENLIST_API_BASE_PATH'); const openListTargetPath = `${remoteDir}${filename}`; const fileStream = fs.createReadStream(tempFilePath); await this.openListService.uploadFile( tempFilePath, fileStream, openListTargetPath, ); await fsp.unlink(tempFilePath); this.logger.log(`表情包上传成功: ${openListTargetPath}`); return res.status(200).json({ message: '表情上传成功!', path: openListTargetPath, filename, }); } catch (error) { this.logger.error('表情包上传失败:', error); throw new HttpException( `上传失败: ${error.message || error}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } }