Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package com.jootalkpia.file_server.controller;

import com.jootalkpia.file_server.dto.ChangeProfileResponseDto;
import com.jootalkpia.file_server.dto.MultipartChunk;
import com.jootalkpia.file_server.dto.UploadChunkRequestDto;
import com.jootalkpia.file_server.dto.UploadFileRequestDto;
import com.jootalkpia.file_server.dto.UploadFileResponseDto;
import com.jootalkpia.file_server.dto.UploadFilesResponseDto;
import com.jootalkpia.file_server.service.FileService;
import com.jootalkpia.file_server.utils.ValidationUtils;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.InputStreamResource;
Expand All @@ -17,13 +21,14 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;

@RestController
@RequestMapping("/api/v1")
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
@Slf4j
public class FileController {
Expand All @@ -36,8 +41,47 @@ public ResponseEntity<String> testEndpoint() {
return ResponseEntity.ok("Test successful");
}

@PostMapping("/files")
public ResponseEntity<UploadFileResponseDto> uploadFiles(@ModelAttribute UploadFileRequestDto uploadFileRequest) {
// @PostMapping("/init-upload")
// public ResponseEntity<?> initFileUpload(@RequestBody UploadChunkRequestDto request) {
// log.info("Received init-upload request: {}", request);
//
// ValidationUtils.validateWorkSpaceId(request.getWorkspaceId());
// ValidationUtils.validateChannelId(request.getChannelId());
//
// return ResponseEntity.ok();
// }

@PostMapping("/chunk")
public ResponseEntity<?> uploadFileChunk(
@RequestParam("workspaceId") Long workspaceId,
@RequestParam("channelId") Long channelId,
@RequestParam("tempFileIdentifier") String tempFileIdentifier,
@RequestParam("totalChunks") Long totalChunks,
@RequestParam("chunkSize") Long chunkSize,
@RequestParam("chunkInfo.chunkIndex") Long chunkIndex,
@RequestPart("chunkInfo.chunk") MultipartFile chunk) {

log.info("청크 업로드 요청: chunkIndex={}, totalChunks={}", chunkIndex, totalChunks);

ValidationUtils.validateWorkSpaceId(workspaceId);
ValidationUtils.validateChannelId(channelId);
ValidationUtils.validateFile(chunk);
ValidationUtils.validateFileId(tempFileIdentifier);
ValidationUtils.validateTotalChunksAndChunkIndex(totalChunks, chunkIndex);

// DTO로 변환
MultipartChunk multipartChunk = new MultipartChunk(chunkIndex, chunk);
UploadChunkRequestDto request = new UploadChunkRequestDto(
workspaceId, channelId, tempFileIdentifier, totalChunks, chunkSize, multipartChunk
);

Object response = fileService.uploadFileChunk(request);
return ResponseEntity.ok(response);
}


@PostMapping
public ResponseEntity<UploadFilesResponseDto> uploadFiles(@ModelAttribute UploadFileRequestDto uploadFileRequest) {
log.info("got uploadFileRequest: {}", uploadFileRequest);
ValidationUtils.validateLengthOfFilesAndThumbnails(uploadFileRequest.getFiles().length, uploadFileRequest.getThumbnails().length);
ValidationUtils.validateWorkSpaceId(uploadFileRequest.getWorkspaceId());
Expand All @@ -46,11 +90,11 @@ public ResponseEntity<UploadFileResponseDto> uploadFiles(@ModelAttribute UploadF
ValidationUtils.validateFiles(uploadFileRequest.getThumbnails());

log.info("got uploadFileRequest: {}", uploadFileRequest.getFiles().length);
UploadFileResponseDto response = fileService.uploadFiles(userId, uploadFileRequest);
UploadFilesResponseDto response = fileService.uploadFiles(userId, uploadFileRequest);
return ResponseEntity.ok(response);
}

@GetMapping("/files/{fileId}")
@GetMapping("/{fileId}")
public ResponseEntity<InputStreamResource> downloadFile(@PathVariable Long fileId) {
log.info("got downloadFile id: {}", fileId);
ValidationUtils.validateFileId(fileId);
Expand Down Expand Up @@ -86,4 +130,4 @@ public ResponseEntity<ChangeProfileResponseDto> changeProfile(
ChangeProfileResponseDto response = fileService.changeProfile(userId, newImage);
return ResponseEntity.ok(response);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.jootalkpia.file_server.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MultipartChunk {
private Long chunkIndex;
private MultipartFile chunk;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.jootalkpia.file_server.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UploadChunkRequestDto {
private Long workspaceId;
private Long channelId;
private String tempFileIdentifier;
private Long totalChunks;
private Long chunkSize;
private MultipartChunk chunkInfo;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package com.jootalkpia.file_server.dto;

import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@Setter
@AllArgsConstructor
public class UploadFileResponseDto {
private List<String> fileTypes; // IMAGE, VIDEO, THUMBNAIL
private List<Long> fileIds;
}
private Long fileId;
private String fileType;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.jootalkpia.file_server.dto;

import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@AllArgsConstructor
public class UploadFilesResponseDto {
private List<String> fileTypes; // IMAGE, VIDEO, THUMBNAIL
private List<Long> fileIds;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ public enum ErrorCode {
IMAGE_UPLOAD_FAILED("F50003", "파일 업로드에 실패했습니다."),
IMAGE_DOWNLOAD_FAILED("F50004", "파일 다운로드 중 오류가 발생했습니다."),
FILE_PROCESSING_FAILED("F50005", "파일 처리 중 오류가 발생했습니다."),
CHUNK_PROCESSING_FAILED("F50008", "청크 처리 중 오류가 발생했습니다."),
CHUNK_MERGING_FAILED("F50007", "청크 병합 중 오류가 발생했습니다."),
MIMETYPE_DETECTION_FAILED("F50009", "mimetype 감지에 실패했습니다."),
UNEXPECTED_ERROR("F50006", "예상치 못한 오류가 발생했습니다.");

private final String code;
private final String msg;
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
package com.jootalkpia.file_server.service;

import com.jootalkpia.file_server.dto.ChangeProfileResponseDto;
import com.jootalkpia.file_server.dto.MultipartChunk;
import com.jootalkpia.file_server.dto.UploadChunkRequestDto;
import com.jootalkpia.file_server.dto.UploadFileRequestDto;
import com.jootalkpia.file_server.dto.UploadFileResponseDto;
import com.jootalkpia.file_server.dto.UploadFilesResponseDto;
import com.jootalkpia.file_server.entity.Files;
import com.jootalkpia.file_server.entity.User;
import com.jootalkpia.file_server.exception.common.CustomException;
import com.jootalkpia.file_server.exception.common.ErrorCode;
import com.jootalkpia.file_server.repository.FileRepository;
import com.jootalkpia.file_server.repository.UserRepository;
import jakarta.transaction.Transactional;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -27,9 +41,113 @@ public class FileService {
private final FileRepository fileRepository;
private final S3Service s3Service;
private final UserRepository userRepository;
private final FileTypeDetector fileTypeDetector;

// 청크 저장을 위한 Map (tempFileIdentifier 기준으로 리스트 저장)
private static final ConcurrentHashMap<String, List<File>> TEMP_FILE_STORAGE = new ConcurrentHashMap<>();

@Transactional
public Object uploadFileChunk(UploadChunkRequestDto request) {
MultipartFile chunkFile = request.getChunkInfo().getChunk();
String tempFileIdentifier = request.getTempFileIdentifier();
int totalChunks = request.getTotalChunks().intValue();
int chunkIndex = request.getChunkInfo().getChunkIndex().intValue();

log.info("Processing chunk {} of {}", chunkIndex, totalChunks);

try {
// 청크 저장 리스트 불러오고 없으면 생성
List<File> chunkList = TEMP_FILE_STORAGE.computeIfAbsent(tempFileIdentifier, k -> new ArrayList<>(totalChunks));

// 리스트 크기를 totalChunks 크기로 확장
while (chunkList.size() < totalChunks) {
chunkList.add(null);
}

int adjustedIndex = chunkIndex - 1;

File tempChunkFile = File.createTempFile("chunk_" + chunkIndex, ".part");
appendChunkToFile(tempChunkFile, chunkFile);
chunkList.set(adjustedIndex, tempChunkFile);

// 모든 청크가 수신 완료되었는지 확인 (전체 크기가 맞으면 병합)
if (chunkList.size() == totalChunks && chunkList.stream().allMatch(java.util.Objects::nonNull)) {
log.info("모든 청크가 도착함 - 병합 시작 (임시 파일 ID: {})", tempFileIdentifier);
return finalizeFileUpload(tempFileIdentifier, chunkList);
}
} catch (IOException e) {
throw new CustomException(ErrorCode.CHUNK_PROCESSING_FAILED.getCode(), ErrorCode.CHUNK_PROCESSING_FAILED.getMsg());
}

// 병합이 완료되지 않은 경우 기본 응답 반환
Map<String, Object> response = new HashMap<>();
response.put("code", 200);
response.put("status", "partial");
return response;
}

private UploadFileResponseDto finalizeFileUpload(String tempFileIdentifier, List<File> chunkList) {
try {
File mergedFile = mergeChunks(chunkList, tempFileIdentifier);

Files filesEntity = new Files();
fileRepository.save(filesEntity);
Long fileId = filesEntity.getFileId();

// S3 업로드
String fileType = fileTypeDetector.detectFileTypeFromFile(mergedFile);
String s3Url = s3Service.uploadFileMultipart(mergedFile, fileType.toLowerCase() + "s/", fileId);

filesEntity.setUrl(s3Url);
filesEntity.setFileType(fileType);
filesEntity.setFileSize(mergedFile.length());
fileRepository.save(filesEntity);

// 임시 데이터 정리
TEMP_FILE_STORAGE.remove(tempFileIdentifier);
chunkList.forEach(File::delete);

return new UploadFileResponseDto(fileId, fileType);
} catch (IOException e) {
throw new CustomException(ErrorCode.FILE_PROCESSING_FAILED.getCode(), ErrorCode.FILE_PROCESSING_FAILED.getMsg());
}
}

// 청크를 임시 파일에 추가
private void appendChunkToFile(File tempFile, MultipartFile chunkFile) throws IOException {
try (FileOutputStream fos = new FileOutputStream(tempFile);
InputStream inputStream = chunkFile.getInputStream()) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
}

// 청크 파일 병합
private File mergeChunks(List<File> chunkList, String tempFileIdentifier) throws IOException {
File mergedFile = File.createTempFile("merged_" + tempFileIdentifier, ".tmp");

try (FileOutputStream fos = new FileOutputStream(mergedFile)) {
for (File chunk : chunkList) {
try (InputStream inputStream = new FileInputStream(chunk)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
}
} catch (IOException e) {
throw new CustomException(ErrorCode.CHUNK_MERGING_FAILED.getCode(), ErrorCode.CHUNK_MERGING_FAILED.getMsg());
}

return mergedFile;
}

@Transactional
public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploadFileRequestDto) {
public UploadFilesResponseDto uploadFiles(Long userId, UploadFileRequestDto uploadFileRequestDto) {
MultipartFile[] files = uploadFileRequestDto.getFiles();
MultipartFile[] thumbnails = uploadFileRequestDto.getThumbnails();

Expand All @@ -47,7 +165,7 @@ public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploa
MultipartFile file = files[i];

// 파일 타입 결정
String fileType = FileTypeDetector.detectFileTypeFromMultipartFile(file);
String fileType = fileTypeDetector.detectFileTypeFromMultipartFile(file);
log.info(fileType);

String s3Url = uploadEachFile(fileType, fileId, file);
Expand All @@ -68,7 +186,7 @@ public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploa
log.info("now saving filesentity");
fileRepository.save(filesEntity);
}
return new UploadFileResponseDto(fileTypes, fileIds);
return new UploadFilesResponseDto(fileTypes, fileIds);
}

private String uploadEachFile(String fileType, Long fileId, MultipartFile file) {
Expand Down Expand Up @@ -106,7 +224,7 @@ public ChangeProfileResponseDto changeProfile(Long userId, MultipartFile newImag
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND.getCode(), ErrorCode.USER_NOT_FOUND.getMsg()));

try {
String fileType = FileTypeDetector.detectFileTypeFromMultipartFile(newImage);
String fileType = fileTypeDetector.detectFileTypeFromMultipartFile(newImage);
String s3Url = uploadEachFile(fileType, userId, newImage);

user.updateProfileImage(s3Url);
Expand All @@ -117,4 +235,4 @@ public ChangeProfileResponseDto changeProfile(Long userId, MultipartFile newImag
throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED.getCode(), ErrorCode.IMAGE_UPLOAD_FAILED.getMsg());
}
}
}
}
Loading