feat: 新增图片资源上传和管理服务

This commit is contained in:
R524809
2026-01-07 17:09:00 +08:00
parent 457ba6d765
commit 67e4dc6382
15 changed files with 838 additions and 39 deletions

View File

@@ -11,6 +11,9 @@ DB_DATABASE=vest_mind_dev
JWT_SECRET=vest_thinking_key
JWT_EXPIRES_IN=7d
# 资源上传配置
STORAGE_TYPE=local
ADMIN_USERNAME=joey
ADMIN_PASSWORD=joey5628
ADMIN_EMAIL=zhangyi5628@126.com

View File

@@ -5,6 +5,7 @@ import { CoreModule } from './core/core.module';
import { BrokerModule } from './modules/broker/broker.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { StorageModule } from './modules/storage/storage.module';
@Module({
imports: [
@@ -20,6 +21,7 @@ import { AuthModule } from './modules/auth/auth.module';
BrokerModule,
UserModule,
AuthModule,
StorageModule,
],
controllers: [],
providers: [],

View File

@@ -6,14 +6,23 @@ import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import bodyParser from 'body-parser';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const configService = new ConfigService();
// const isProduction = configService.get('NODE_ENV') === 'production';
const app = await NestFactory.create(AppModule, {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bodyParser: false, // 禁用默认 bodyParser使用自定义配置
});
// 配置静态文件服务
const storagePath =
configService.get<string>('STORAGE_PATH') || './uploads';
app.useStaticAssets(join(process.cwd(), storagePath), {
prefix: '/uploads/',
});
// 安全头设置(必须在其他中间件之前)
app.use(helmet());

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BrokerService } from './broker.service';
import { BrokerController } from './broker.controller';
import { Broker } from './broker.entity';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [TypeOrmModule.forFeature([Broker])],
imports: [TypeOrmModule.forFeature([Broker]), StorageModule],
controllers: [BrokerController],
providers: [BrokerService],
exports: [BrokerService],

View File

@@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
ConflictException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
@@ -11,12 +12,16 @@ import { UpdateBrokerDto } from './dto/update-broker.dto';
import { QueryBrokerDto } from './dto/query-broker.dto';
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
import { StorageService } from '../storage/storage.service';
@Injectable()
export class BrokerService {
private readonly logger = new Logger(BrokerService.name);
constructor(
@InjectRepository(Broker)
private readonly brokerRepository: Repository<Broker>,
private readonly storageService: StorageService,
) {}
/**
@@ -290,9 +295,30 @@ export class BrokerService {
/**
* 删除 broker
* 同时删除券商Logo图片
*/
async remove(id: number): Promise<void> {
const broker = await this.findOne(id);
// 删除券商Logo图片
if (broker.brokerImage) {
try {
const imagePath = this.storageService.extractStoragePath(
broker.brokerImage,
);
if (imagePath) {
await this.storageService.delete(imagePath);
this.logger.log(`已删除券商Logo: ${imagePath}`);
}
} catch (error) {
// 图片删除失败不影响券商删除操作
this.logger.warn(
`删除券商Logo失败: ${broker.brokerImage}`,
error,
);
}
}
await this.brokerRepository.remove(broker);
}
}

View File

@@ -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;
}

View File

@@ -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>;
}

View File

@@ -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}`;
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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;
}
}
}

View File

@@ -4,9 +4,10 @@ import { UserService } from './user.service';
import { UserController } from './user.controller';
import { UserSeeder } from './user.seeder';
import { User } from './user.entity';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [TypeOrmModule.forFeature([User])],
imports: [TypeOrmModule.forFeature([User]), StorageModule],
controllers: [UserController],
providers: [UserService, UserSeeder],
exports: [UserService],

View File

@@ -3,6 +3,7 @@ import {
NotFoundException,
ConflictException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
@@ -13,12 +14,16 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUserDto } from './dto/query-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
import { StorageService } from '../storage/storage.service';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly storageService: StorageService,
) {}
/**
@@ -315,9 +320,27 @@ export class UserService {
/**
* 删除用户(软删除,更新状态为 deleted
* 同时删除用户头像图片
*/
async remove(id: number): Promise<void> {
const user = await this.findOneById(id);
// 删除用户头像图片
if (user.avatarUrl) {
try {
const imagePath = this.storageService.extractStoragePath(
user.avatarUrl,
);
if (imagePath) {
await this.storageService.delete(imagePath);
this.logger.log(`已删除用户头像: ${imagePath}`);
}
} catch (error) {
// 图片删除失败不影响用户删除操作
this.logger.warn(`删除用户头像失败: ${user.avatarUrl}`, error);
}
}
user.status = 'deleted';
await this.userRepository.save(user);
}