Skip to content

Commit ce78307

Browse files
committed
Merge branch 'main' into refactor/convention
2 parents 73f277c + 5e538ec commit ce78307

File tree

13 files changed

+195
-101
lines changed

13 files changed

+195
-101
lines changed

nginx.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ server {
3131

3232
# 파일 업로드 서비스에 의해 관리되는 정적 파일 서빙
3333
location /objects {
34-
alias /var/denamu_objects/;
34+
alias /var/web05-Denamu/objects/;
3535
try_files $uri $uri/ =404;
3636
}
3737

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { diskStorage } from 'multer';
2+
import {
3+
createDirectoryIfNotExists,
4+
getFileName,
5+
validateAndGetUploadType,
6+
} from './file-utils';
7+
import { validateFile, FILE_SIZE_LIMITS } from './file-validator';
8+
9+
export const createDynamicStorage = () => {
10+
return {
11+
storage: diskStorage({
12+
destination: (req: any, file, cb) => {
13+
try {
14+
const uploadType = validateAndGetUploadType(req.query.uploadType);
15+
const uploadPath = createDirectoryIfNotExists(uploadType);
16+
cb(null, uploadPath);
17+
} catch (error) {
18+
cb(error, null);
19+
}
20+
},
21+
filename: (req, file, cb) => {
22+
cb(null, getFileName(file));
23+
},
24+
}),
25+
fileFilter: (req: any, file: any, cb: any) => {
26+
try {
27+
const uploadType = validateAndGetUploadType(req.query.uploadType);
28+
validateFile(file, uploadType);
29+
cb(null, true);
30+
} catch (error) {
31+
cb(error, false);
32+
}
33+
},
34+
limits: {
35+
fileSize: FILE_SIZE_LIMITS.IMAGE, // 기본적으로 이미지 크기 제한 사용
36+
},
37+
};
38+
};
39+
40+
export const storage = createDynamicStorage();

server/src/common/disk/diskStorage.ts

Lines changed: 0 additions & 54 deletions
This file was deleted.
Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { ensureDirSync } from 'fs-extra';
33
import { promises as fs } from 'fs';
44
import { existsSync } from 'fs';
55
import { v4 as uuidv4 } from 'uuid';
6+
import { BadRequestException } from '@nestjs/common';
7+
import { FileUploadType } from './file-validator';
68

7-
// TODO: 테스트 후 기본 경로 제거 하기.
8-
const BASE_UPLOAD_PATH =
9-
process.env.UPLOAD_BASE_PATH || '/var/web05-Denamu/objects';
9+
const BASE_UPLOAD_PATH = '/var/web05-Denamu/objects';
1010

1111
export const generateFilePath = (originalPath: string): string => {
1212
const now = new Date();
@@ -34,3 +34,20 @@ export const deleteFileIfExists = async (filePath: string): Promise<void> => {
3434
await fs.unlink(filePath);
3535
}
3636
};
37+
38+
// Interceptor가 Pipes보다 먼저 실행되기에, 타입 유효성 검사 필요함
39+
export const validateAndGetUploadType = (uploadType: any): FileUploadType => {
40+
if (!uploadType) {
41+
throw new BadRequestException(
42+
`uploadType이 필요합니다. 허용된 타입: ${Object.values(FileUploadType).join(', ')}`,
43+
);
44+
}
45+
46+
if (!Object.values(FileUploadType).includes(uploadType)) {
47+
throw new BadRequestException(
48+
`유효하지 않은 파일 업로드 타입입니다. 허용된 타입: ${Object.values(FileUploadType).join(', ')}`,
49+
);
50+
}
51+
52+
return uploadType as FileUploadType;
53+
};
Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ export const ALLOWED_MIME_TYPES = {
55
ALL: [] as string[],
66
};
77

8-
export const FILE_UPLOAD_TYPE = {
9-
PROFILE_IMAGE: 'profileImg',
10-
} as const;
11-
12-
export type FileUploadType = keyof typeof FILE_UPLOAD_TYPE;
8+
export enum FileUploadType {
9+
PROFILE_IMAGE = 'PROFILE_IMAGE',
10+
// 추후 추가될 타입들 명시
11+
}
1312

1413
ALLOWED_MIME_TYPES.ALL = [...ALLOWED_MIME_TYPES.IMAGE];
1514

@@ -19,11 +18,11 @@ export const FILE_SIZE_LIMITS = {
1918
DEFAULT: 10 * 1024 * 1024,
2019
};
2120

22-
export const validateFile = (file: any, uploadType: string) => {
21+
export const validateFile = (file: any, uploadType: FileUploadType) => {
2322
let allowedTypes: string[] = [];
24-
if (uploadType === 'PROFILE_IMAGE') {
23+
if (uploadType === FileUploadType.PROFILE_IMAGE) {
2524
allowedTypes = ALLOWED_MIME_TYPES.IMAGE;
26-
}
25+
} // else if 구문 이나 switch 써서 타입 추가되면 유효성 ALLOWED TYPES 매핑해주기!
2726

2827
validateFileType(file, allowedTypes);
2928
validateFileSize(file, uploadType);
@@ -39,10 +38,10 @@ const validateFileType = (file: any, allowedTypes?: string[]) => {
3938
}
4039
};
4140

42-
const validateFileSize = (file: any, uploadType: string) => {
41+
const validateFileSize = (file: any, uploadType: FileUploadType) => {
4342
let sizeLimit: number;
4443

45-
if (uploadType === 'PROFILE_IMAGE') {
44+
if (uploadType === FileUploadType.PROFILE_IMAGE) {
4645
sizeLimit = FILE_SIZE_LIMITS.IMAGE;
4746
} else {
4847
sizeLimit = FILE_SIZE_LIMITS.DEFAULT;

server/src/file/api-docs/uploadProfileFile.api-docs.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,25 @@ import {
55
ApiConsumes,
66
ApiOkResponse,
77
ApiOperation,
8+
ApiQuery,
89
ApiUnauthorizedResponse,
910
} from '@nestjs/swagger';
11+
import { FileUploadType } from '../../common/disk/file-validator';
1012

1113
export function ApiUploadProfileFile() {
1214
return applyDecorators(
1315
ApiOperation({
14-
summary: '프로필 이미지 업로드 API',
15-
description: '사용자의 프로필 이미지를 업로드합니다.',
16+
summary: '파일 업로드 API',
17+
description: '사용자의 파일을 업로드합니다.',
1618
}),
1719
ApiConsumes('multipart/form-data'),
20+
ApiQuery({
21+
name: 'uploadType',
22+
description: '파일 업로드 타입',
23+
enum: FileUploadType,
24+
example: FileUploadType.PROFILE_IMAGE,
25+
required: true,
26+
}),
1827
ApiBody({
1928
description: '업로드할 파일',
2029
schema: {
@@ -23,7 +32,7 @@ export function ApiUploadProfileFile() {
2332
file: {
2433
type: 'string',
2534
format: 'binary',
26-
description: '업로드할 이미지 파일 (JPG, PNG, GIF 등)',
35+
description: '업로드할 파일 (uploadType별 허용 형식 다름!)',
2736
},
2837
},
2938
required: ['file'],
@@ -62,7 +71,8 @@ export function ApiUploadProfileFile() {
6271
},
6372
url: {
6473
type: 'string',
65-
example: '/objects/profile/2024/01/profile-image.jpg',
74+
example:
75+
'/objects/PROFILE_IMAGE/20241215/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg',
6676
description: '파일 접근 URL',
6777
},
6878
userId: {
@@ -84,10 +94,32 @@ export function ApiUploadProfileFile() {
8494
ApiBadRequestResponse({
8595
description: '잘못된 요청',
8696
schema: {
87-
properties: {
88-
message: {
89-
type: 'string',
90-
example: '파일이 선택되지 않았습니다.',
97+
examples: {
98+
fileNotSelected: {
99+
summary: '파일 미선택',
100+
value: {
101+
message: '파일이 선택되지 않았습니다.',
102+
},
103+
},
104+
invalidUploadType: {
105+
summary: '잘못된 업로드 타입',
106+
value: {
107+
message:
108+
'유효하지 않은 파일 업로드 타입입니다. 허용된 타입: PROFILE_IMAGE',
109+
},
110+
},
111+
invalidFileType: {
112+
summary: '지원하지 않는 파일 형식',
113+
value: {
114+
message:
115+
'지원하지 않는 파일 형식입니다. 지원 형식: image/jpeg, image/png, image/gif, image/webp',
116+
},
117+
},
118+
fileSizeExceeded: {
119+
summary: '파일 크기 초과',
120+
value: {
121+
message: '파일 크기가 너무 큽니다. 최대 5MB까지 허용됩니다.',
122+
},
91123
},
92124
},
93125
},

server/src/file/controller/file.controller.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,36 @@ import {
88
Param,
99
UseGuards,
1010
BadRequestException,
11+
Query,
1112
HttpStatus,
1213
HttpCode,
1314
} from '@nestjs/common';
1415
import { FileInterceptor } from '@nestjs/platform-express';
1516
import { FileService } from '../service/file.service';
1617
import { ApiTags } from '@nestjs/swagger';
1718
import { JwtGuard } from '../../common/guard/jwt.guard';
18-
import { createDynamicStorage } from '../../common/disk/diskStorage';
19+
import { createDynamicStorage } from '../../common/disk/disk-storage';
1920
import { ApiResponse } from '../../common/response/common.response';
2021
import { ApiUploadProfileFile } from '../api-docs/uploadProfileFile.api-docs';
2122
import { ApiDeleteFile } from '../api-docs/deleteFile.api-docs';
2223
import { DeleteFileRequestDto } from '../dto/request/deleteFile.dto';
24+
import { FileUploadQueryDto } from '../dto/request/fileUpload.dto';
2325

2426
@ApiTags('File')
2527
@Controller('file')
2628
@UseGuards(JwtGuard)
2729
export class FileController {
2830
constructor(private readonly fileService: FileService) {}
2931

30-
@Post('profile')
32+
@Post('')
3133
@ApiUploadProfileFile()
3234
@HttpCode(HttpStatus.CREATED)
3335
@UseInterceptors(FileInterceptor('file', createDynamicStorage()))
34-
async upload(@UploadedFile() file: any, @Req() req) {
36+
async upload(
37+
@UploadedFile() file: any,
38+
@Query() query: FileUploadQueryDto,
39+
@Req() req,
40+
) {
3541
if (!file) {
3642
throw new BadRequestException('파일이 선택되지 않았습니다.');
3743
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { IsEnum, IsOptional } from 'class-validator';
2+
import { Transform } from 'class-transformer';
3+
import { ApiProperty } from '@nestjs/swagger';
4+
import { FileUploadType } from '../../../common/disk/file-validator';
5+
6+
export class FileUploadQueryDto {
7+
@ApiProperty({
8+
description: '파일 업로드 타입',
9+
enum: FileUploadType,
10+
example: FileUploadType.PROFILE_IMAGE,
11+
required: false,
12+
})
13+
uploadType: FileUploadType;
14+
}

server/src/file/service/file.service.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { Injectable, NotFoundException } from '@nestjs/common';
22
import { File } from '../entity/file.entity';
3-
import { unlinkSync, existsSync } from 'fs';
3+
import { unlink, access } from 'fs/promises';
44
import { FileRepository } from '../repository/file.repository';
55
import { User } from '../../user/entity/user.entity';
66
import { UploadFileResponseDto } from '../dto/response/uploadFile.dto';
7+
import { WinstonLoggerService } from '../../common/logger/logger.service';
78

89
@Injectable()
910
export class FileService {
10-
constructor(private readonly fileRepository: FileRepository) {}
11+
constructor(
12+
private readonly fileRepository: FileRepository,
13+
private readonly logger: WinstonLoggerService,
14+
) {}
1115

1216
async create(file: any, userId: number): Promise<UploadFileResponseDto> {
13-
const { originalName, mimetype, size, path } = file;
14-
17+
const { originalname, mimetype, size, path } = file;
1518
const savedFile = await this.fileRepository.save({
16-
originalName,
19+
originalName: originalname,
1720
mimetype,
1821
size,
1922
path,
@@ -25,8 +28,7 @@ export class FileService {
2528
}
2629

2730
private generateAccessUrl(filePath: string): string {
28-
const baseUploadPath =
29-
process.env.UPLOAD_BASE_PATH || '/var/web05-Denamu/objects';
31+
const baseUploadPath = '/var/web05-Denamu/objects';
3032
const relativePath = filePath.replace(baseUploadPath, '');
3133
return `/objects${relativePath}`;
3234
}
@@ -42,8 +44,11 @@ export class FileService {
4244
async deleteFile(id: number): Promise<void> {
4345
const file = await this.findById(id);
4446

45-
if (existsSync(file.path)) {
46-
unlinkSync(file.path);
47+
try {
48+
await access(file.path);
49+
await unlink(file.path);
50+
} catch (error) {
51+
this.logger.warn(`파일 삭제 실패: ${file.path}`, 'FileService');
4752
}
4853

4954
await this.fileRepository.delete(id);
@@ -52,4 +57,20 @@ export class FileService {
5257
async getFileInfo(id: number): Promise<File> {
5358
return this.findById(id);
5459
}
60+
61+
async deleteByPath(path: string): Promise<void> {
62+
const file = await this.fileRepository.findOne({ where: { path } });
63+
if (file) {
64+
try {
65+
await access(file.path);
66+
await unlink(file.path);
67+
} catch (error) {
68+
this.logger.warn(`파일 삭제 실패: ${file.path}`, 'FileService');
69+
}
70+
71+
await this.fileRepository.delete(file.id);
72+
} else {
73+
throw new NotFoundException('파일을 찾을 수 없습니다.');
74+
}
75+
}
5576
}

0 commit comments

Comments
 (0)