feat: 新增图片资源上传和管理服务
This commit is contained in:
@@ -11,6 +11,9 @@ DB_DATABASE=vest_mind_dev
|
|||||||
JWT_SECRET=vest_thinking_key
|
JWT_SECRET=vest_thinking_key
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 资源上传配置
|
||||||
|
STORAGE_TYPE=local
|
||||||
|
|
||||||
ADMIN_USERNAME=joey
|
ADMIN_USERNAME=joey
|
||||||
ADMIN_PASSWORD=joey5628
|
ADMIN_PASSWORD=joey5628
|
||||||
ADMIN_EMAIL=zhangyi5628@126.com
|
ADMIN_EMAIL=zhangyi5628@126.com
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CoreModule } from './core/core.module';
|
|||||||
import { BrokerModule } from './modules/broker/broker.module';
|
import { BrokerModule } from './modules/broker/broker.module';
|
||||||
import { UserModule } from './modules/user/user.module';
|
import { UserModule } from './modules/user/user.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { StorageModule } from './modules/storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -20,6 +21,7 @@ import { AuthModule } from './modules/auth/auth.module';
|
|||||||
BrokerModule,
|
BrokerModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
StorageModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -6,14 +6,23 @@ import helmet from 'helmet';
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const configService = new ConfigService();
|
const configService = new ConfigService();
|
||||||
// const isProduction = configService.get('NODE_ENV') === 'production';
|
// const isProduction = configService.get('NODE_ENV') === 'production';
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
bodyParser: false, // 禁用默认 bodyParser,使用自定义配置
|
bodyParser: false, // 禁用默认 bodyParser,使用自定义配置
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 配置静态文件服务
|
||||||
|
const storagePath =
|
||||||
|
configService.get<string>('STORAGE_PATH') || './uploads';
|
||||||
|
app.useStaticAssets(join(process.cwd(), storagePath), {
|
||||||
|
prefix: '/uploads/',
|
||||||
|
});
|
||||||
|
|
||||||
// 安全头设置(必须在其他中间件之前)
|
// 安全头设置(必须在其他中间件之前)
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { BrokerService } from './broker.service';
|
import { BrokerService } from './broker.service';
|
||||||
import { BrokerController } from './broker.controller';
|
import { BrokerController } from './broker.controller';
|
||||||
import { Broker } from './broker.entity';
|
import { Broker } from './broker.entity';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Broker])],
|
imports: [TypeOrmModule.forFeature([Broker]), StorageModule],
|
||||||
controllers: [BrokerController],
|
controllers: [BrokerController],
|
||||||
providers: [BrokerService],
|
providers: [BrokerService],
|
||||||
exports: [BrokerService],
|
exports: [BrokerService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, FindOptionsWhere } from '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 { QueryBrokerDto } from './dto/query-broker.dto';
|
||||||
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto';
|
||||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerService {
|
export class BrokerService {
|
||||||
|
private readonly logger = new Logger(BrokerService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Broker)
|
@InjectRepository(Broker)
|
||||||
private readonly brokerRepository: Repository<Broker>,
|
private readonly brokerRepository: Repository<Broker>,
|
||||||
|
private readonly storageService: StorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,9 +295,30 @@ export class BrokerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除 broker
|
* 删除 broker
|
||||||
|
* 同时删除券商Logo图片
|
||||||
*/
|
*/
|
||||||
async remove(id: number): Promise<void> {
|
async remove(id: number): Promise<void> {
|
||||||
const broker = await this.findOne(id);
|
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);
|
await this.brokerRepository.remove(broker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/api/src/modules/storage/dto/upload-file.dto.ts
Normal file
22
apps/api/src/modules/storage/dto/upload-file.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
173
apps/api/src/modules/storage/providers/local-storage.provider.ts
Normal file
173
apps/api/src/modules/storage/providers/local-storage.provider.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
244
apps/api/src/modules/storage/storage.controller.ts
Normal file
244
apps/api/src/modules/storage/storage.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/modules/storage/storage.module.ts
Normal file
10
apps/api/src/modules/storage/storage.module.ts
Normal 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 {}
|
||||||
102
apps/api/src/modules/storage/storage.service.ts
Normal file
102
apps/api/src/modules/storage/storage.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import { UserService } from './user.service';
|
|||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserSeeder } from './user.seeder';
|
import { UserSeeder } from './user.seeder';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User])],
|
imports: [TypeOrmModule.forFeature([User]), StorageModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService, UserSeeder],
|
providers: [UserService, UserSeeder],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, FindOptionsWhere } from '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 { QueryUserDto } from './dto/query-user.dto';
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private readonly logger = new Logger(UserService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
|
private readonly storageService: StorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -315,9 +320,27 @@ export class UserService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除用户(软删除,更新状态为 deleted)
|
* 删除用户(软删除,更新状态为 deleted)
|
||||||
|
* 同时删除用户头像图片
|
||||||
*/
|
*/
|
||||||
async remove(id: number): Promise<void> {
|
async remove(id: number): Promise<void> {
|
||||||
const user = await this.findOneById(id);
|
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';
|
user.status = 'deleted';
|
||||||
await this.userRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Modal, Form, Input, Select, Upload, message } from 'antd';
|
import { Modal, Form, Input, Select, Upload, message } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps, UploadFile } from 'antd';
|
||||||
import { brokerService } from '@/services/broker';
|
import { brokerService } from '@/services/broker';
|
||||||
|
import { storageService } from '@/services/storage';
|
||||||
import type { Broker, CreateBrokerRequest } from '@/types/broker';
|
import type { Broker, CreateBrokerRequest } from '@/types/broker';
|
||||||
import { REGION_OPTIONS } from '@/types/broker';
|
import { REGION_OPTIONS } from '@/types/broker';
|
||||||
|
import type { RcFile } from 'antd/es/upload';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ interface BrokerFormModalProps {
|
|||||||
|
|
||||||
const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: BrokerFormModalProps) => {
|
const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: BrokerFormModalProps) => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const isEdit = !!editingBroker;
|
const isEdit = !!editingBroker;
|
||||||
|
|
||||||
// 当编辑数据变化时,更新表单
|
// 当编辑数据变化时,更新表单
|
||||||
@@ -29,45 +33,90 @@ const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: Broker
|
|||||||
region: editingBroker.region,
|
region: editingBroker.region,
|
||||||
brokerImage: editingBroker.brokerImage,
|
brokerImage: editingBroker.brokerImage,
|
||||||
});
|
});
|
||||||
|
// 如果有图片,设置文件列表
|
||||||
|
if (editingBroker.brokerImage) {
|
||||||
|
setFileList([
|
||||||
|
{
|
||||||
|
uid: '-1',
|
||||||
|
name: 'broker-logo',
|
||||||
|
status: 'done',
|
||||||
|
url: editingBroker.brokerImage,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setFileList([]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
setFileList([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [visible, editingBroker, form]);
|
}, [visible, editingBroker, form]);
|
||||||
|
|
||||||
// 图片上传配置(仅UI,不上传)
|
// 自定义上传函数
|
||||||
|
const customRequest: UploadProps['customRequest'] = async (options) => {
|
||||||
|
const { file, onSuccess, onError } = options;
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadFile = file as RcFile;
|
||||||
|
const response = await storageService.uploadFile({
|
||||||
|
file: uploadFile,
|
||||||
|
folder: 'broker',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新表单字段
|
||||||
|
form.setFieldValue('brokerImage', response.url);
|
||||||
|
|
||||||
|
// 更新文件列表
|
||||||
|
setFileList([
|
||||||
|
{
|
||||||
|
uid: response.path,
|
||||||
|
name: response.filename,
|
||||||
|
status: 'done',
|
||||||
|
url: response.url,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
message.success('图片上传成功');
|
||||||
|
onSuccess?.(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '图片上传失败');
|
||||||
|
onError?.(error);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片上传配置
|
||||||
const uploadProps: UploadProps = {
|
const uploadProps: UploadProps = {
|
||||||
name: 'file',
|
name: 'file',
|
||||||
listType: 'picture-card',
|
listType: 'picture-card',
|
||||||
maxCount: 1,
|
maxCount: 1,
|
||||||
beforeUpload: () => {
|
fileList,
|
||||||
// 阻止自动上传
|
accept: 'image/*',
|
||||||
|
customRequest,
|
||||||
|
beforeUpload: (file) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件!');
|
||||||
return false;
|
return false;
|
||||||
},
|
|
||||||
onChange: (info) => {
|
|
||||||
if (info.file.status === 'done') {
|
|
||||||
message.success(`${info.file.name} 文件上传成功`);
|
|
||||||
} else if (info.file.status === 'error') {
|
|
||||||
message.error(`${info.file.name} 文件上传失败`);
|
|
||||||
}
|
}
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||||
|
if (!isLt5M) {
|
||||||
|
message.error('图片大小不能超过 5MB!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
onRemove: () => {
|
onRemove: () => {
|
||||||
|
setFileList([]);
|
||||||
form.setFieldValue('brokerImage', '');
|
form.setFieldValue('brokerImage', '');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onChange: ({ fileList: newFileList }) => {
|
||||||
|
setFileList(newFileList);
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
// 处理图片变化(仅UI,实际应该上传后获取URL)
|
|
||||||
const handleImageChange = (info: any) => {
|
|
||||||
// 这里只做UI处理,实际应该调用上传接口获取图片URL
|
|
||||||
// 临时处理:使用本地预览
|
|
||||||
if (info.file) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
// 实际应该调用上传接口,这里只是示例
|
|
||||||
// form.setFieldValue('brokerImage', uploadUrl);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(info.file);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
@@ -148,20 +197,27 @@ const BrokerFormModal = ({ visible, editingBroker, onCancel, onSuccess }: Broker
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="brokerImage"
|
name="brokerImage"
|
||||||
label="券商Logo"
|
label="券商Logo"
|
||||||
rules={[{ max: 200, message: '图片地址不能超过200个字符' }]}
|
rules={[{ max: 500, message: '图片地址不能超过500个字符' }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="请输入图片URL(或使用下方上传组件)" allowClear />
|
<Input
|
||||||
|
placeholder="图片URL(上传后自动填充)"
|
||||||
|
allowClear
|
||||||
|
readOnly
|
||||||
|
style={{ cursor: 'not-allowed' }}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="上传Logo(仅UI演示)">
|
<Form.Item label="上传Logo">
|
||||||
<Upload {...uploadProps} onChange={handleImageChange} accept="image/*">
|
<Upload {...uploadProps}>
|
||||||
|
{fileList.length >= 1 ? null : (
|
||||||
<div>
|
<div>
|
||||||
<PlusOutlined />
|
{uploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||||
<div style={{ marginTop: 8 }}>上传</div>
|
<div style={{ marginTop: 8 }}>上传</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Upload>
|
</Upload>
|
||||||
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
||||||
注意:上传功能仅做UI演示,实际需要调用上传接口获取图片URL
|
支持 JPG、PNG、GIF、WebP 格式,大小不超过 5MB
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
67
apps/web/src/services/storage.ts
Normal file
67
apps/web/src/services/storage.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export interface UploadFileParams {
|
||||||
|
file: File;
|
||||||
|
folder?: 'broker' | 'user' | 'temp';
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFileResponse {
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
mimetype: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储服务
|
||||||
|
*/
|
||||||
|
class StorageService {
|
||||||
|
/**
|
||||||
|
* 管理员上传文件(需要鉴权)
|
||||||
|
* 用于上传券商Logo等基础数据
|
||||||
|
*/
|
||||||
|
async uploadFile(params: UploadFileParams): Promise<UploadFileResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', params.file);
|
||||||
|
|
||||||
|
if (params.folder) {
|
||||||
|
formData.append('folder', params.folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filename) {
|
||||||
|
formData.append('filename', params.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await api.post<UploadFileResponse>('/storage/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传用户头像(不需要鉴权)
|
||||||
|
* 用于用户注册或更新头像
|
||||||
|
*/
|
||||||
|
async uploadAvatar(file: File): Promise<UploadFileResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return await api.post<UploadFileResponse>('/storage/upload/avatar', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件(需要管理员权限)
|
||||||
|
*/
|
||||||
|
async deleteFile(path: string): Promise<void> {
|
||||||
|
await api.delete(`/storage/${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storageService = new StorageService();
|
||||||
Reference in New Issue
Block a user