feat: 支持图片上传接口
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as STS from 'qcloud-cos-sts';
|
||||
import * as COS from 'cos-nodejs-sdk-v5';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,20 +11,25 @@ export class CosService {
|
||||
private readonly secretKey: string;
|
||||
private readonly bucket: string;
|
||||
private readonly region: string;
|
||||
private readonly cdnDomain: string;
|
||||
private readonly allowPrefix: string;
|
||||
private readonly cosClient: COS;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.secretId = this.configService.get<string>('TENCENT_SECRET_ID') || '';
|
||||
this.secretKey = this.configService.get<string>('TENCENT_SECRET_KEY') || '';
|
||||
this.bucket = this.configService.get<string>('COS_BUCKET') || '';
|
||||
this.region = this.configService.get<string>('COS_REGION') || 'ap-guangzhou';
|
||||
this.cdnDomain = this.configService.get<string>('COS_CDN_DOMAIN') || 'https://cdn.richarjiang.com';
|
||||
this.allowPrefix = this.configService.get<string>('COS_ALLOW_PREFIX') || 'uploads/*';
|
||||
|
||||
if (!this.secretId || !this.secretKey || !this.bucket) {
|
||||
throw new Error('腾讯云COS配置缺失:TENCENT_SECRET_ID, TENCENT_SECRET_KEY, COS_BUCKET 是必需的');
|
||||
}
|
||||
|
||||
// 初始化COS客户端
|
||||
this.cosClient = new COS({
|
||||
SecretId: this.secretId,
|
||||
SecretKey: this.secretKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +59,6 @@ export class CosService {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
prefix: prefix,
|
||||
cdnDomain: this.cdnDomain,
|
||||
};
|
||||
|
||||
this.logger.log(`临时密钥获取成功,用户ID: ${userId}, 有效期至: ${new Date(stsResult.expiredTime * 1000)}`);
|
||||
@@ -172,12 +177,8 @@ export class CosService {
|
||||
/**
|
||||
* 生成文件访问URL
|
||||
*/
|
||||
private generateFileUrl(fileKey: string): string {
|
||||
if (this.cdnDomain) {
|
||||
return `${this.cdnDomain}/${fileKey}`;
|
||||
} else {
|
||||
return `https://${this.bucket}.cos.${this.region}.myqcloud.com/${fileKey}`;
|
||||
}
|
||||
private generateFileUrl(uploadResult: COS.UploadFileResult): string {
|
||||
return `https://${uploadResult.Location}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,4 +226,75 @@ export class CosService {
|
||||
|
||||
return limits[fileType as keyof typeof limits] || 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接上传图片到COS
|
||||
*/
|
||||
async uploadImage(userId: string, file: Express.Multer.File): Promise<any> {
|
||||
try {
|
||||
this.logger.log(`开始上传图片,用户ID: ${userId}, 原始文件名: ${file.originalname}`);
|
||||
|
||||
// 验证文件类型
|
||||
const fileExtension = this.getFileExtension(file.originalname);
|
||||
if (!this.validateFileType('image', fileExtension)) {
|
||||
throw new BadRequestException('不支持的图片格式');
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
const sizeLimit = this.getFileSizeLimit('image');
|
||||
if (file.size > sizeLimit) {
|
||||
throw new BadRequestException(`图片文件大小超过限制 (${sizeLimit / 1024 / 1024}MB)`);
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
const uniqueFileName = this.generateUniqueFileName(file.originalname, fileExtension);
|
||||
const fileKey = `uploads/images/${uniqueFileName}`;
|
||||
|
||||
// 上传到COS
|
||||
const uploadResult = await this.uploadToCos(fileKey, file.buffer, file.mimetype);
|
||||
|
||||
// 生成文件访问URL
|
||||
const fileUrl = this.generateFileUrl(uploadResult);
|
||||
|
||||
const response = {
|
||||
fileUrl,
|
||||
fileKey,
|
||||
originalName: file.originalname,
|
||||
fileSize: file.size,
|
||||
fileType: 'image',
|
||||
mimeType: file.mimetype,
|
||||
uploadTime: new Date(),
|
||||
etag: uploadResult.ETag,
|
||||
};
|
||||
|
||||
this.logger.log(`图片上传成功,用户ID: ${userId}, 文件URL: ${fileUrl}`);
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`上传图片失败: ${error.message}`, error.stack);
|
||||
throw new BadRequestException(`上传图片失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到COS
|
||||
*/
|
||||
private async uploadToCos(key: string, body: Buffer, contentType?: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.cosClient.putObject({
|
||||
Bucket: this.bucket,
|
||||
Region: this.region,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: body.length,
|
||||
}, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/users/dto/upload-image.dto.ts
Normal file
27
src/users/dto/upload-image.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UploadImageResponseDto {
|
||||
@ApiProperty({ description: '文件访问URL', example: 'https://cdn.richarjiang.com/uploads/images/1673123456789-abc123.jpg' })
|
||||
fileUrl: string;
|
||||
|
||||
@ApiProperty({ description: '文件存储键', example: 'uploads/images/1673123456789-abc123.jpg' })
|
||||
fileKey: string;
|
||||
|
||||
@ApiProperty({ description: '原始文件名', example: 'photo.jpg' })
|
||||
originalName: string;
|
||||
|
||||
@ApiProperty({ description: '文件大小(字节)', example: 1024000 })
|
||||
fileSize: number;
|
||||
|
||||
@ApiProperty({ description: '文件类型', example: 'image' })
|
||||
fileType: string;
|
||||
|
||||
@ApiProperty({ description: 'MIME类型', example: 'image/jpeg' })
|
||||
mimeType: string;
|
||||
|
||||
@ApiProperty({ description: '上传时间', example: '2024-01-08T10:30:45.123Z' })
|
||||
uploadTime: Date;
|
||||
|
||||
@ApiProperty({ description: 'COS ETag', example: '"abc123def456"' })
|
||||
etag: string;
|
||||
}
|
||||
@@ -14,11 +14,15 @@ import {
|
||||
Inject,
|
||||
Req,
|
||||
NotFoundException,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Request } from 'express';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger as WinstonLogger } from 'winston';
|
||||
import { UsersService } from './users.service';
|
||||
import { UploadImageResponseDto } from './dto/upload-image.dto';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UserResponseDto } from './dto/user-response.dto';
|
||||
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||
@@ -193,6 +197,49 @@ export class UsersController {
|
||||
}
|
||||
}
|
||||
|
||||
// 上传图片到COS
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('cos/upload-image')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiOperation({ summary: '上传图片到COS' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '图片上传成功',
|
||||
type: UploadImageResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '上传失败:文件格式不支持或文件过大',
|
||||
})
|
||||
async uploadImageToCos(
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
try {
|
||||
if (!file) {
|
||||
return { code: ResponseCode.ERROR, message: '请选择要上传的图片文件' };
|
||||
}
|
||||
|
||||
|
||||
this.winstonLogger.log(`file: ${file}`, {
|
||||
context: 'UsersController',
|
||||
userId: user?.sub,
|
||||
})
|
||||
|
||||
const data = await this.cosService.uploadImage(user.sub, file);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.winstonLogger.error('上传图片失败', {
|
||||
context: 'UsersController',
|
||||
userId: user?.sub,
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
});
|
||||
return { code: ResponseCode.ERROR, message: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// App Store 服务器通知接收接口
|
||||
@Public()
|
||||
@Post('app-store-notifications')
|
||||
|
||||
Reference in New Issue
Block a user