feat:文件上传功能

This commit is contained in:
Jerry 2025-10-11 22:58:13 +08:00
parent d0eb167245
commit 645065b4fb
4 changed files with 144 additions and 70 deletions

View File

@ -51,8 +51,6 @@
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",

80
pnpm-lock.yaml generated
View File

@ -47,6 +47,9 @@ importers:
moment: moment:
specifier: ^2.30.1 specifier: ^2.30.1
version: 2.30.1 version: 2.30.1
multer:
specifier: ^2.0.2
version: 2.0.2
reflect-metadata: reflect-metadata:
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2 version: 0.2.2
@ -96,6 +99,9 @@ importers:
'@types/jest': '@types/jest':
specifier: ^29.5.14 specifier: ^29.5.14
version: 29.5.14 version: 29.5.14
'@types/multer':
specifier: ^2.0.0
version: 2.0.0
'@types/node': '@types/node':
specifier: ^22.16.4 specifier: ^22.16.4
version: 22.16.5 version: 22.16.5
@ -111,12 +117,6 @@ importers:
eslint: eslint:
specifier: ^9.18.0 specifier: ^9.18.0
version: 9.31.0 version: 9.31.0
eslint-config-prettier:
specifier: ^10.0.1
version: 10.1.8(eslint@9.31.0)
eslint-plugin-prettier:
specifier: ^5.2.2
version: 5.5.3(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.31.0))(eslint@9.31.0)(prettier@3.6.2)
globals: globals:
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.3.0 version: 16.3.0
@ -931,10 +931,6 @@ packages:
'@paralleldrive/cuid2@2.2.2': '@paralleldrive/cuid2@2.2.2':
resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==}
'@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@scarf/scarf@1.4.0': '@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
@ -1135,6 +1131,9 @@ packages:
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/multer@2.0.0':
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
'@types/node@22.16.5': '@types/node@22.16.5':
resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==}
@ -1908,26 +1907,6 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-plugin-prettier@5.5.3:
resolution: {integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-scope@5.1.1: eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -2026,9 +2005,6 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-fifo@1.3.2: fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
@ -2999,10 +2975,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.0:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier@3.6.2: prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -3377,10 +3349,6 @@ packages:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
synckit@0.11.11:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0}
tapable@2.2.2: tapable@2.2.2:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -4631,8 +4599,6 @@ snapshots:
dependencies: dependencies:
'@noble/hashes': 1.8.0 '@noble/hashes': 1.8.0
'@pkgr/core@0.2.9': {}
'@scarf/scarf@1.4.0': {} '@scarf/scarf@1.4.0': {}
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
@ -4830,6 +4796,10 @@ snapshots:
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/multer@2.0.0':
dependencies:
'@types/express': 5.0.3
'@types/node@22.16.5': '@types/node@22.16.5':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@ -5679,20 +5649,6 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@9.31.0):
dependencies:
eslint: 9.31.0
eslint-plugin-prettier@5.5.3(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.31.0))(eslint@9.31.0)(prettier@3.6.2):
dependencies:
eslint: 9.31.0
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.11
optionalDependencies:
'@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@9.31.0)
eslint-scope@5.1.1: eslint-scope@5.1.1:
dependencies: dependencies:
esrecurse: 4.3.0 esrecurse: 4.3.0
@ -5856,8 +5812,6 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-fifo@1.3.2: {} fast-fifo@1.3.2: {}
fast-glob@3.3.3: fast-glob@3.3.3:
@ -6947,10 +6901,6 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
dependencies:
fast-diff: 1.3.0
prettier@3.6.2: {} prettier@3.6.2: {}
pretty-format@29.7.0: pretty-format@29.7.0:
@ -7364,10 +7314,6 @@ snapshots:
symbol-observable@4.0.0: {} symbol-observable@4.0.0: {}
synckit@0.11.11:
dependencies:
'@pkgr/core': 0.2.9
tapable@2.2.2: {} tapable@2.2.2: {}
tar-stream@3.1.7: tar-stream@3.1.7:

View File

@ -10,8 +10,18 @@ import {
Logger, Logger,
Inject, Inject,
Ip, Ip,
UseInterceptors,
UploadedFile,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger'; import {
ApiTags,
ApiOperation,
ApiBody,
ApiQuery,
ApiConsumes,
ApiHeader,
} from '@nestjs/swagger';
import { MemeService } from './meme.service'; import { MemeService } from './meme.service';
import { Response } from 'express'; import { Response } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
@ -19,6 +29,12 @@ import { Throttle } from 'stream-throttle';
import { ToolsService } from '../../core/tools/tools.service'; import { ToolsService } from '../../core/tools/tools.service';
import { RedisService } from '../../core/redis/redis.service'; import { RedisService } from '../../core/redis/redis.service';
import imageType from 'image-type'; 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 { class MemeRequestDto {
character?: string; character?: string;
@ -38,6 +54,12 @@ export class MemeController {
private readonly toolsService: ToolsService, private readonly toolsService: ToolsService,
@Inject(RedisService) @Inject(RedisService)
private readonly redisService: 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') @Post('get')
@ -158,4 +180,111 @@ export class MemeController {
throw new HttpException('服务器错误', HttpStatus.INTERNAL_SERVER_ERROR); 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,
);
}
}
} }

View File

@ -16,6 +16,7 @@ import { FilesModule } from '../../core/files/files.module';
RedisModule, RedisModule,
AppConfigModule, AppConfigModule,
FilesModule, FilesModule,
PathModule,
], ],
providers: [MemeService], providers: [MemeService],
controllers: [MemeController], controllers: [MemeController],