This commit is contained in:
2026-04-24 20:08:23 +08:00
parent b7ba9a26b0
commit 86b9456ecd
18 changed files with 661 additions and 61 deletions
+2
View File
@@ -34,6 +34,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.4.2",
"multer": "^2.1.1",
"mysql2": "^3.12.0",
"nestjs-rate-limiter": "^3.1.0",
"passport": "^0.7.0",
@@ -53,6 +54,7 @@
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
+2
View File
@@ -17,6 +17,7 @@ import { ReviewModule } from './modules/review/review.module';
import { FinanceModule } from './modules/finance/finance.module';
import { ActivityModule } from './modules/activity/activity.module';
import { PlatformConfigModule } from './modules/config/config.module';
import { UploadModule } from './modules/upload/upload.module';
import { ScheduleModule as TaskScheduleModule } from './schedule/schedule.module';
@Module({
@@ -55,6 +56,7 @@ import { ScheduleModule as TaskScheduleModule } from './schedule/schedule.module
OrderModule,
AdminAuthModule,
PlatformConfigModule,
UploadModule,
],
})
export class AppModule {}
+5 -1
View File
@@ -6,13 +6,15 @@ if (!globalThis.crypto) {
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api');
@@ -21,6 +23,8 @@ async function bootstrap() {
credentials: true,
});
app.useStaticAssets(join(__dirname, '..', 'uploads'), { prefix: '/uploads/' });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@@ -2,6 +2,7 @@ import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ConfigService } from './config.service';
import { JwtAuthGuard, RolesGuard, Roles } from '@/common';
import { UploadService } from '../upload/upload.service';
@ApiTags('管理端-系统配置')
@ApiBearerAuth()
@@ -9,7 +10,10 @@ import { JwtAuthGuard, RolesGuard, Roles } from '@/common';
@Roles('admin')
@Controller('admin/config')
export class ConfigController {
constructor(private readonly configService: ConfigService) {}
constructor(
private readonly configService: ConfigService,
private readonly uploadService: UploadService,
) {}
@Get('service-fee')
@ApiOperation({ summary: '获取服务费配置' })
@@ -23,4 +27,17 @@ export class ConfigController {
await this.configService.updateServiceFeeRate(body.rate);
return this.configService.getServiceFeeConfig();
}
@Get('storage')
@ApiOperation({ summary: '获取存储配置' })
async getStorageConfig() {
return this.uploadService.getStorageConfig();
}
@Put('storage')
@ApiOperation({ summary: '更新存储配置' })
async updateStorageConfig(@Body() body: Record<string, string>) {
await this.uploadService.updateStorageConfig(body);
return this.uploadService.getStorageConfig();
}
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlatformConfig } from '@/entities/platform-config.entity';
import { ConfigService } from './config.service';
import { ConfigController } from './config.controller';
import { UploadModule } from '../upload/upload.module';
@Module({
imports: [TypeOrmModule.forFeature([PlatformConfig])],
imports: [TypeOrmModule.forFeature([PlatformConfig]), forwardRef(() => UploadModule)],
controllers: [ConfigController],
providers: [ConfigService],
exports: [ConfigService],
@@ -0,0 +1,68 @@
import {
Controller,
Post,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { SellerJwtAuthGuard } from '@/common/guards/seller-jwt-auth.guard';
import { RolesGuard, Roles } from '@/common';
import { UploadService } from './upload.service';
const uploadOptions = {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req: any, file: Express.Multer.File, cb: any) => {
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif|webp|bmp)$/)) {
cb(new BadRequestException('只支持图片文件'), false);
return;
}
cb(null, true);
},
};
@ApiTags('文件上传')
@Controller()
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post('upload')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '用户上传图片' })
@ApiConsumes('multipart/form-data')
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
@UseInterceptors(FileInterceptor('file', uploadOptions))
async uploadUser(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('请选择文件');
return this.uploadService.upload(file);
}
@Post('seller/upload')
@UseGuards(SellerJwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '商家上传图片' })
@ApiConsumes('multipart/form-data')
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
@UseInterceptors(FileInterceptor('file', uploadOptions))
async uploadSeller(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('请选择文件');
return this.uploadService.upload(file);
}
@Post('admin/upload')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
@ApiOperation({ summary: '管理员上传图片' })
@ApiConsumes('multipart/form-data')
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
@UseInterceptors(FileInterceptor('file', uploadOptions))
async uploadAdmin(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('请选择文件');
return this.uploadService.upload(file);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlatformConfig } from '@/entities/platform-config.entity';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
@Module({
imports: [TypeOrmModule.forFeature([PlatformConfig])],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,178 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService as NestConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlatformConfig } from '@/entities/platform-config.entity';
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
export type StorageProvider = 'local' | 'tencent_cos' | 'aliyun_oss';
export interface UploadResult {
url: string;
filename: string;
originalname: string;
size: number;
}
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
constructor(
@InjectRepository(PlatformConfig)
private configRepo: Repository<PlatformConfig>,
private nestConfigService: NestConfigService,
) {}
async getStorageProvider(): Promise<StorageProvider> {
const config = await this.configRepo.findOne({ where: { configKey: 'storage_provider' } });
return (config?.configValue as StorageProvider) || 'local';
}
async upload(file: Express.Multer.File): Promise<UploadResult> {
const provider = await this.getStorageProvider();
this.logger.log(`上传文件: ${file.originalname}, 存储方式: ${provider}`);
switch (provider) {
case 'tencent_cos':
return this.uploadTencentCos(file);
case 'aliyun_oss':
return this.uploadAliyunOss(file);
default:
return this.uploadLocal(file);
}
}
private async uploadLocal(file: Express.Multer.File): Promise<UploadResult> {
let uploadDir = './uploads';
const dirConfig = await this.configRepo.findOne({ where: { configKey: 'storage_local_path' } });
if (dirConfig?.configValue) uploadDir = dirConfig.configValue;
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const ext = path.extname(file.originalname) || '.jpg';
const filename = `${uuidv4()}${ext}`;
const filepath = path.join(uploadDir, filename);
fs.writeFileSync(filepath, file.buffer);
return {
url: `/uploads/${filename}`,
filename,
originalname: file.originalname,
size: file.size,
};
}
private async uploadTencentCos(file: Express.Multer.File): Promise<UploadResult> {
try {
// 动态导入,避免未安装时报错
const COS = (await import('cos-nodejs-sdk-v5')).default;
const configs = await this.getStorageConfigs('storage_cos_');
const cos = new COS({
SecretId: configs['secret_id'],
SecretKey: configs['secret_key'],
});
const ext = path.extname(file.originalname) || '.jpg';
const filename = `${uuidv4()}${ext}`;
const key = `uploads/${filename}`;
await new Promise<void>((resolve, reject) => {
cos.putObject(
{
Bucket: configs['bucket'],
Region: configs['region'],
Key: key,
Body: file.buffer,
},
(err) => (err ? reject(err) : resolve()),
);
});
return {
url: `https://${configs['bucket']}.cos.${configs['region']}.myqcloud.com/${key}`,
filename,
originalname: file.originalname,
size: file.size,
};
} catch (err: any) {
if (err.code === 'MODULE_NOT_FOUND') {
throw new Error('腾讯云COS SDK未安装,请运行: pnpm add cos-nodejs-sdk-v5');
}
throw err;
}
}
private async uploadAliyunOss(file: Express.Multer.File): Promise<UploadResult> {
try {
const OSS = (await import('ali-oss')).default;
const configs = await this.getStorageConfigs('storage_oss_');
const client = new OSS({
region: configs['region'],
accessKeyId: configs['access_key_id'],
accessKeySecret: configs['access_key_secret'],
bucket: configs['bucket'],
});
const ext = path.extname(file.originalname) || '.jpg';
const filename = `${uuidv4()}${ext}`;
const key = `uploads/${filename}`;
await client.put(key, file.buffer);
return {
url: `https://${configs['bucket']}.${configs['region']}.aliyuncs.com/${key}`,
filename,
originalname: file.originalname,
size: file.size,
};
} catch (err: any) {
if (err.code === 'MODULE_NOT_FOUND') {
throw new Error('阿里云OSS SDK未安装,请运行: pnpm add ali-oss');
}
throw err;
}
}
private async getStorageConfigs(prefix: string): Promise<Record<string, string>> {
const configs = await this.configRepo.find();
const result: Record<string, string> = {};
for (const c of configs) {
if (c.configKey.startsWith(prefix)) {
const key = c.configKey.replace(prefix, '');
result[key] = c.configValue;
}
}
return result;
}
async getStorageConfig(): Promise<Record<string, string>> {
const configs = await this.configRepo.find();
const result: Record<string, string> = {};
for (const c of configs) {
if (c.configKey.startsWith('storage_')) {
result[c.configKey] = c.configValue;
}
}
return result;
}
async updateStorageConfig(configs: Record<string, string>): Promise<void> {
for (const [key, value] of Object.entries(configs)) {
if (!key.startsWith('storage_')) continue;
let config = await this.configRepo.findOne({ where: { configKey: key } });
if (config) {
config.configValue = value;
} else {
config = this.configRepo.create({ configKey: key, configValue: value });
}
await this.configRepo.save(config);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
declare module 'cos-nodejs-sdk-v5' {
interface CosOptions {
SecretId: string;
SecretKey: string;
}
interface PutObjectParams {
Bucket: string;
Region: string;
Key: string;
Body: any;
}
class COS {
constructor(options: CosOptions);
putObject(params: PutObjectParams, callback: (err: any, data: any) => void): void;
}
export default COS;
}
declare module 'ali-oss' {
interface OssOptions {
region: string;
accessKeyId: string;
accessKeySecret: string;
bucket: string;
}
class OSS {
constructor(options: OssOptions);
put(name: string, data: any): Promise<{ name: string; url: string }>;
}
export default OSS;
}