feat: 新增图片资源上传和管理服务
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsIn } from 'class-validator';
|
||||
|
||||
export class UploadFileDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '存储文件夹',
|
||||
example: 'broker',
|
||||
enum: ['broker', 'user', 'temp'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['broker', 'user', 'temp'])
|
||||
folder?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '自定义文件名',
|
||||
example: 'custom-filename.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
filename?: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 文件上传对象类型
|
||||
*/
|
||||
export interface FileUpload {
|
||||
fieldname: string;
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储提供者接口
|
||||
* 所有存储实现都必须实现此接口,方便切换不同的存储方案
|
||||
*/
|
||||
export interface UploadResult {
|
||||
path: string; // 存储路径(相对路径或完整路径)
|
||||
url: string; // 访问URL
|
||||
filename: string; // 文件名
|
||||
size: number; // 文件大小(字节)
|
||||
mimetype: string; // MIME类型
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
folder?: string; // 存储文件夹,如 'broker', 'user' 等
|
||||
filename?: string; // 自定义文件名
|
||||
maxSize?: number; // 最大文件大小(字节)
|
||||
allowedMimeTypes?: string[]; // 允许的MIME类型
|
||||
}
|
||||
|
||||
export interface IStorageProvider {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param file 文件对象
|
||||
* @param options 上传选项
|
||||
* @returns 上传结果
|
||||
*/
|
||||
upload(file: FileUpload, options?: UploadOptions): Promise<UploadResult>;
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
delete(path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取文件的访问URL
|
||||
* @param path 文件路径
|
||||
* @returns 访问URL
|
||||
*/
|
||||
getUrl(path: string): string;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param path 文件路径
|
||||
* @returns 是否存在
|
||||
*/
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import type {
|
||||
IStorageProvider,
|
||||
UploadResult,
|
||||
UploadOptions,
|
||||
FileUpload,
|
||||
} from '../interfaces/storage-provider.interface';
|
||||
|
||||
/**
|
||||
* 本地存储提供者
|
||||
* 将文件存储在服务器本地目录
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalStorageProvider implements IStorageProvider {
|
||||
private readonly logger = new Logger(LocalStorageProvider.name);
|
||||
private readonly basePath: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// 从环境变量读取配置
|
||||
this.basePath =
|
||||
this.configService.get<string>('STORAGE_PATH') || './uploads';
|
||||
this.baseUrl =
|
||||
this.configService.get<string>('STORAGE_BASE_URL') ||
|
||||
'http://localhost:3200/uploads';
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到本地存储
|
||||
*/
|
||||
async upload(
|
||||
file: FileUpload,
|
||||
options?: UploadOptions,
|
||||
): Promise<UploadResult> {
|
||||
try {
|
||||
// 验证文件大小
|
||||
if (options?.maxSize && file.size > options.maxSize) {
|
||||
throw new Error(
|
||||
`文件大小超过限制 ${options.maxSize / 1024 / 1024}MB`,
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (
|
||||
options?.allowedMimeTypes &&
|
||||
!options.allowedMimeTypes.includes(file.mimetype)
|
||||
) {
|
||||
throw new Error(
|
||||
`不支持的文件类型,允许的类型:${options.allowedMimeTypes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 确定存储文件夹
|
||||
const folder = options?.folder || 'temp';
|
||||
const uploadDir = path.join(this.basePath, folder);
|
||||
|
||||
// 确保目录存在
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
|
||||
// 生成文件名
|
||||
const filename = this.generateFilename(file, options?.filename);
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
const relativePath = path
|
||||
.join(folder, filename)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
// 保存文件
|
||||
await fs.writeFile(filePath, file.buffer);
|
||||
|
||||
// 生成访问URL
|
||||
const url = `${this.baseUrl}/${relativePath}`;
|
||||
|
||||
this.logger.log(`文件上传成功: ${relativePath}`);
|
||||
|
||||
return {
|
||||
path: relativePath,
|
||||
url,
|
||||
filename,
|
||||
size: file.size,
|
||||
mimetype: file.mimetype,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : '未知错误';
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`文件上传失败: ${errorMessage}`, errorStack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(filePath: string): Promise<void> {
|
||||
try {
|
||||
// filePath 是相对路径(如 broker/filename.jpg)
|
||||
const fullPath = path.join(this.basePath, filePath);
|
||||
|
||||
// 安全检查:确保文件路径在 basePath 内,防止路径遍历攻击
|
||||
const resolvedPath = path.resolve(fullPath);
|
||||
const resolvedBasePath = path.resolve(this.basePath);
|
||||
if (!resolvedPath.startsWith(resolvedBasePath)) {
|
||||
throw new Error('非法文件路径');
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(resolvedPath);
|
||||
} catch {
|
||||
this.logger.warn(`文件不存在: ${resolvedPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.unlink(resolvedPath);
|
||||
this.logger.log(`文件删除成功: ${resolvedPath}`);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : '未知错误';
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`文件删除失败: ${errorMessage}`, errorStack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的访问URL
|
||||
*/
|
||||
getUrl(filePath: string): string {
|
||||
const relativePath = filePath.startsWith(this.basePath)
|
||||
? filePath.replace(this.basePath, '').replace(/^[/\\]/, '')
|
||||
: filePath;
|
||||
return `${this.baseUrl}/${relativePath}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fullPath = filePath.startsWith(this.basePath)
|
||||
? filePath
|
||||
: path.join(this.basePath, filePath);
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名
|
||||
* 格式: {timestamp}-{random}-{originalname}
|
||||
*/
|
||||
private generateFilename(file: FileUpload, customName?: string): string {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const random = crypto.randomBytes(8).toString('hex');
|
||||
const ext = path.extname(file.originalname);
|
||||
const nameWithoutExt = path.basename(file.originalname, ext);
|
||||
|
||||
// 清理文件名,移除特殊字符
|
||||
const cleanName = nameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
return `${timestamp}-${random}-${cleanName}${ext}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiConsumes,
|
||||
ApiBody,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { StorageService } from './storage.service';
|
||||
import { UploadFileDto } from './dto/upload-file.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { FileUpload } from './interfaces/storage-provider.interface';
|
||||
|
||||
@ApiTags('storage')
|
||||
@Controller('storage')
|
||||
export class StorageController {
|
||||
constructor(private readonly storageService: StorageService) {}
|
||||
|
||||
/**
|
||||
* 管理员上传文件(需要鉴权)
|
||||
* 用于上传券商Logo等基础数据
|
||||
*/
|
||||
@Post('upload')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: '管理员上传文件',
|
||||
description: '上传单个文件,需要管理员权限,用于上传券商Logo等基础数据',
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: '要上传的文件',
|
||||
},
|
||||
folder: {
|
||||
type: 'string',
|
||||
enum: ['broker', 'user', 'temp'],
|
||||
description: '存储文件夹',
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: '自定义文件名',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '上传成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
example: 'broker/1234567890-abcdef-broker-logo.jpg',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
example:
|
||||
'http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg',
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
example: '1234567890-abcdef-broker-logo.jpg',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
example: 102400,
|
||||
},
|
||||
mimetype: {
|
||||
type: 'string',
|
||||
example: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '文件格式或大小不符合要求' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
async uploadAdmin(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB
|
||||
new FileTypeValidator({
|
||||
fileType: /^image\/(jpeg|jpg|png|gif|webp)$/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: FileUpload,
|
||||
@Body() uploadDto: UploadFileDto,
|
||||
) {
|
||||
const options = {
|
||||
folder: uploadDto.folder || 'temp',
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
allowedMimeTypes: [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
],
|
||||
filename: uploadDto.filename,
|
||||
};
|
||||
|
||||
return await this.storageService.upload(file, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户上传头像(不需要鉴权)
|
||||
* 用于用户注册或更新头像
|
||||
*/
|
||||
@Post('upload/avatar')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: '上传用户头像',
|
||||
description: '上传用户头像,不需要鉴权,用于用户注册或更新头像',
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: '要上传的头像文件',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '上传成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
example: 'user/1234567890-abcdef-avatar.jpg',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
example:
|
||||
'http://localhost:3200/uploads/user/1234567890-abcdef-avatar.jpg',
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
example: '1234567890-abcdef-avatar.jpg',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
example: 102400,
|
||||
},
|
||||
mimetype: {
|
||||
type: 'string',
|
||||
example: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '文件格式或大小不符合要求' })
|
||||
async uploadAvatar(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB
|
||||
new FileTypeValidator({
|
||||
fileType: /^image\/(jpeg|jpg|png|gif|webp)$/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: FileUpload,
|
||||
) {
|
||||
const options = {
|
||||
folder: 'user', // 用户头像固定存储在 user 文件夹
|
||||
maxSize: 2 * 1024 * 1024, // 2MB(头像文件限制更小)
|
||||
allowedMimeTypes: [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
],
|
||||
};
|
||||
|
||||
return await this.storageService.upload(file, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件(需要管理员权限)
|
||||
*/
|
||||
@Delete(':path(*)')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '删除文件',
|
||||
description: '根据文件路径删除文件,需要管理员权限',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'path',
|
||||
description: '文件路径(相对路径)',
|
||||
example: 'broker/1234567890-abcdef-broker-logo.jpg',
|
||||
})
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '文件不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
async delete(@Param('path') filePath: string) {
|
||||
await this.storageService.delete(filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StorageController } from './storage.controller';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StorageController],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LocalStorageProvider } from './providers/local-storage.provider';
|
||||
import type {
|
||||
IStorageProvider,
|
||||
UploadResult,
|
||||
UploadOptions,
|
||||
FileUpload,
|
||||
} from './interfaces/storage-provider.interface';
|
||||
|
||||
/**
|
||||
* 存储服务
|
||||
* 根据配置选择不同的存储提供者
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly provider: IStorageProvider;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// 根据环境变量选择存储提供者
|
||||
const storageType =
|
||||
// 是的,STORAGE_TYPE 应配置在 .env 文件中,否则将默认使用 'local'
|
||||
this.configService.get<string>('STORAGE_TYPE') || 'local';
|
||||
|
||||
this.logger.log(`使用存储类型: ${storageType}`);
|
||||
|
||||
switch (storageType) {
|
||||
case 'local':
|
||||
this.provider = new LocalStorageProvider(configService);
|
||||
break;
|
||||
// 未来可以添加其他存储提供者
|
||||
// case 'qiniu':
|
||||
// this.provider = new QiniuStorageProvider(configService);
|
||||
// break;
|
||||
default:
|
||||
this.provider = new LocalStorageProvider(configService);
|
||||
this.logger.warn(
|
||||
`未知的存储类型 ${storageType},使用默认本地存储`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async upload(
|
||||
file: FileUpload,
|
||||
options?: UploadOptions,
|
||||
): Promise<UploadResult> {
|
||||
return this.provider.upload(file, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(path: string): Promise<void> {
|
||||
return this.provider.delete(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件URL
|
||||
*/
|
||||
getUrl(path: string): string {
|
||||
return this.provider.getUrl(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.provider.exists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取存储路径
|
||||
* 例如: http://localhost:3200/uploads/broker/filename.jpg -> broker/filename.jpg
|
||||
* @param url 完整的文件访问URL
|
||||
* @returns 存储路径(相对路径),如果无法解析则返回null
|
||||
*/
|
||||
extractStoragePath(url: string): string | null {
|
||||
try {
|
||||
// 尝试从URL中提取路径
|
||||
// 格式: http://domain/uploads/folder/filename.ext
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
// 移除 /uploads/ 前缀,获取相对路径
|
||||
const uploadsPrefix = '/uploads/';
|
||||
if (pathname.startsWith(uploadsPrefix)) {
|
||||
return pathname.substring(uploadsPrefix.length);
|
||||
}
|
||||
|
||||
// 如果不是标准格式,尝试直接使用路径名(去掉开头的 /)
|
||||
return pathname.startsWith('/') ? pathname.substring(1) : pathname;
|
||||
} catch (error) {
|
||||
// 如果URL格式不正确,返回null
|
||||
this.logger.warn(`无法解析URL: ${url}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user