-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat/#17] 파일 업로드 URL 발급/삭제 도메인·API 추가 #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e097c78
0e291f6
8453977
911c7a4
4514027
e107d58
5d66d32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package kr.ac.kookmin.stream.internal; | ||
|
|
||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.validation.Valid; | ||
| import java.io.IOException; | ||
| import kr.ac.kookmin.stream.ApiResponse; | ||
| import kr.ac.kookmin.stream.common.PrincipalProvider; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; | ||
| import kr.ac.kookmin.stream.internal.domain.file.service.FileService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.PutMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/v1/admin/files") | ||
| @RequiredArgsConstructor | ||
| public class AdminFileController { | ||
|
|
||
| private static final String LOCAL_UPLOAD_PATH = "/local-upload/"; | ||
|
|
||
| private final FileService fileService; | ||
| private final PrincipalProvider principalProvider; | ||
|
|
||
| @PostMapping("/presigned-url") | ||
| public ApiResponse<FileUploadUrlIssueResponse> issuePresignedUrl(@Valid @RequestBody FileUploadUrlIssueRequest request) { | ||
| FileUploadUrlIssueResult result = fileService.issuePresignedUrl(request.toCommand(principalProvider.userId())); | ||
| return ApiResponse.success(FileUploadUrlIssueResponse.from(result)); | ||
| } | ||
|
|
||
| @DeleteMapping("/{fileId}") | ||
| public ApiResponse<Void> delete(@PathVariable Long fileId) { | ||
| fileService.delete(fileId); | ||
| return ApiResponse.success(); | ||
| } | ||
|
|
||
| /** | ||
| * S3 연동 전 임시 엔드포인트. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다. | ||
| * S3로 전환하면 이 메서드와 LocalFileStorageClient의 로컬 구현을 함께 제거한다. | ||
| */ | ||
| @PutMapping("/local-upload/**") | ||
| public ApiResponse<Void> receiveLocalUpload(HttpServletRequest request) throws IOException { | ||
| String fileKey = extractFileKey(request); | ||
| fileService.receiveUpload(fileKey, request.getInputStream()); | ||
| return ApiResponse.success(); | ||
| } | ||
|
|
||
| private String extractFileKey(HttpServletRequest request) { | ||
| String uri = request.getRequestURI(); | ||
| int index = uri.indexOf(LOCAL_UPLOAD_PATH); | ||
| return uri.substring(index + LOCAL_UPLOAD_PATH.length()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package kr.ac.kookmin.stream.internal; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Positive; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; | ||
|
|
||
| public record FileUploadUrlIssueRequest( | ||
| @NotBlank(message = "파일명은 필수 입력값입니다.") | ||
| String originalName, | ||
|
|
||
| @NotBlank(message = "MIME 타입은 필수 입력값입니다.") | ||
| String contentType, | ||
|
|
||
| @Positive(message = "파일 크기는 0보다 커야 합니다.") | ||
| long fileSize, | ||
|
|
||
| @NotNull(message = "파일 카테고리는 필수 입력값입니다.") | ||
| FileCategory category | ||
| ) { | ||
| public FileUploadUrlIssueCommand toCommand(Long uploaderId) { | ||
| return new FileUploadUrlIssueCommand(originalName, contentType, fileSize, category, uploaderId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package kr.ac.kookmin.stream.internal; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; | ||
|
|
||
| public record FileUploadUrlIssueResponse( | ||
| Long fileId, | ||
| String uploadUrl, | ||
| String fileKey, | ||
| LocalDateTime expiresAt | ||
| ) { | ||
| public static FileUploadUrlIssueResponse from(FileUploadUrlIssueResult result) { | ||
| return new FileUploadUrlIssueResponse(result.fileId(), result.uploadUrl(), result.fileKey(), result.expiresAt()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.client; | ||
|
|
||
| import java.io.InputStream; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; | ||
|
|
||
| public interface FileStorageClient { | ||
| UploadUrl issuePresignedUrl(String fileKey, String contentType); | ||
| void write(String fileKey, InputStream content); | ||
| void deleteObject(String fileKey); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.domain; | ||
|
|
||
| import kr.ac.kookmin.stream.common.ErrorCode; | ||
| import kr.ac.kookmin.stream.common.ErrorStatus; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
| import lombok.experimental.Accessors; | ||
|
|
||
| @Getter | ||
| @Accessors(fluent = true) | ||
| @AllArgsConstructor | ||
| public enum FileErrorCode implements ErrorCode { | ||
|
|
||
| FILE_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 파일입니다."), | ||
| UNSUPPORTED_FILE_EXTENSION(ErrorStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), | ||
| FILE_SIZE_EXCEEDED(ErrorStatus.BAD_REQUEST, "파일의 최대 업로드 용량을 초과했습니다."); | ||
|
|
||
| private final int status; | ||
| private final String message; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.domain; | ||
|
|
||
| public record FileUploadUrlIssueCommand( | ||
| String originalName, | ||
| String contentType, | ||
| long fileSize, | ||
| FileCategory category, | ||
| Long uploaderId | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.domain; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| public record FileUploadUrlIssueResult( | ||
| Long fileId, | ||
| String uploadUrl, | ||
| String fileKey, | ||
| LocalDateTime expiresAt | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.domain; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| public record UploadUrl(String url, LocalDateTime expiresAt) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.repository; | ||
|
|
||
| import java.util.Optional; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.File; | ||
|
|
||
| public interface FileRepository { | ||
| Optional<File> findById(Long id); | ||
| Optional<File> findByFileKey(String fileKey); | ||
| File save(File file); | ||
| void deleteById(Long id); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.service; | ||
|
|
||
| import java.io.InputStream; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; | ||
|
|
||
| public interface FileService { | ||
| FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); | ||
| void receiveUpload(String fileKey, InputStream content); | ||
| void delete(Long fileId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.service.impl; | ||
|
|
||
| import java.io.InputStream; | ||
| import java.util.UUID; | ||
| import kr.ac.kookmin.stream.common.BusinessException; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.File; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; | ||
| import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; | ||
| import kr.ac.kookmin.stream.internal.domain.file.repository.FileRepository; | ||
| import kr.ac.kookmin.stream.internal.domain.file.service.FileService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| class FileServiceImpl implements FileService { | ||
|
|
||
| private static final String FILE_KEY_PREFIX = "files/"; | ||
|
|
||
| private final FileRepository fileRepository; | ||
| private final FileStorageClient fileStorageClient; | ||
|
|
||
| @Override | ||
| @Transactional | ||
| public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command) { | ||
| FileUploadPolicy.validate(command.category(), command.originalName(), command.fileSize()); | ||
|
|
||
| String fileKey = generateFileKey(command.originalName()); | ||
| UploadUrl uploadUrl = fileStorageClient.issuePresignedUrl(fileKey, command.contentType()); | ||
|
|
||
| File file = File.of( | ||
| null, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 객체를 생성할 때 id를 null로 집어넣어서 생성한 후에 Repository에 save하는 것보다는
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 넵 추천해주신 대로 create() 와 of() 구분하여 정적 팩토리 메서드 추가했습니다! |
||
| fileKey, | ||
| command.category(), | ||
| command.originalName(), | ||
| command.fileSize(), | ||
| command.contentType(), | ||
| command.uploaderId() | ||
| ); | ||
| File saved = fileRepository.save(file); | ||
|
|
||
| return new FileUploadUrlIssueResult(saved.getId(), uploadUrl.url(), fileKey, uploadUrl.expiresAt()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 정적 팩토리 메서드로 객체 생성 통일하면 좋을 거 같아요~
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 컨벤션 문서 상 "record(도메인 객체·Command·Request/Response): 표준 생성자를 쓴다." 라고 나와있어 위와 같이 짰는데, "단 타입 변환이 끼면 from(...)/toCommand()를 둔다" - 이 부분 확인했습니다! |
||
| } | ||
|
|
||
| @Override | ||
| @Transactional(readOnly = true) | ||
| public void receiveUpload(String fileKey, InputStream content) { | ||
| fileRepository.findByFileKey(fileKey) | ||
| .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); | ||
| fileStorageClient.write(fileKey, content); | ||
| } | ||
|
|
||
| @Override | ||
| @Transactional | ||
| public void delete(Long fileId) { | ||
| File file = fileRepository.findById(fileId) | ||
| .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); | ||
| fileStorageClient.deleteObject(file.getFileKey()); | ||
| fileRepository.deleteById(fileId); | ||
| } | ||
|
|
||
| private String generateFileKey(String originalName) { | ||
| String extension = FileUploadPolicy.extractExtension(originalName); | ||
| String key = UUID.randomUUID().toString(); | ||
| return extension.isEmpty() ? FILE_KEY_PREFIX + key : FILE_KEY_PREFIX + key + "." + extension; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package kr.ac.kookmin.stream.internal.domain.file.service.impl; | ||
|
|
||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
| import java.util.stream.Stream; | ||
| import kr.ac.kookmin.stream.common.BusinessException; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory; | ||
| import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; | ||
|
|
||
| final class FileUploadPolicy { | ||
|
|
||
| private static final Set<String> IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); | ||
| private static final Set<String> ATTACHMENT_EXTENSIONS = Set.of("pdf", "doc", "docx", "hwp", "zip"); | ||
|
|
||
| private static final long IMAGE_MAX_FILE_SIZE = 10 * 1024 * 1024; | ||
| private static final long ATTACHMENT_MAX_FILE_SIZE = 20 * 1024 * 1024; | ||
|
|
||
| private static final Map<FileCategory, Set<String>> ALLOWED_EXTENSIONS = Map.of( | ||
| FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS), | ||
| FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_EXTENSIONS, | ||
| FileCategory.NOTICE_IMAGE, IMAGE_EXTENSIONS, | ||
| FileCategory.EVENT_IMAGE, IMAGE_EXTENSIONS, | ||
| FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS | ||
| ); | ||
|
|
||
| private static final Map<FileCategory, Long> MAX_FILE_SIZE = Map.of( | ||
| FileCategory.TEMP, ATTACHMENT_MAX_FILE_SIZE, | ||
| FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_MAX_FILE_SIZE, | ||
| FileCategory.NOTICE_IMAGE, IMAGE_MAX_FILE_SIZE, | ||
| FileCategory.EVENT_IMAGE, IMAGE_MAX_FILE_SIZE, | ||
| FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE | ||
| ); | ||
|
|
||
| private FileUploadPolicy() {} | ||
|
|
||
| static void validate(FileCategory category, String originalName, long fileSize) { | ||
| String extension = extractExtension(originalName); | ||
| if (!ALLOWED_EXTENSIONS.get(category).contains(extension)) { | ||
| throw new BusinessException(FileErrorCode.UNSUPPORTED_FILE_EXTENSION); | ||
| } | ||
| if (fileSize <= 0 || fileSize > MAX_FILE_SIZE.get(category)) { | ||
| throw new BusinessException(FileErrorCode.FILE_SIZE_EXCEEDED); | ||
| } | ||
| } | ||
|
|
||
| static String extractExtension(String originalName) { | ||
| int dotIndex = originalName.lastIndexOf('.'); | ||
| if (dotIndex == -1 || dotIndex == originalName.length() - 1) { | ||
| return ""; | ||
| } | ||
| return originalName.substring(dotIndex + 1).toLowerCase(); | ||
| } | ||
|
|
||
| private static Set<String> union(Set<String> first, Set<String> second) { | ||
| return Stream.concat(first.stream(), second.stream()) | ||
| .collect(Collectors.toUnmodifiableSet()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
임시 엔드포인트는 추후 삭제 편의를 위해 별도 컨트롤러 클래스를 만들어서 분리하는 건 어떤가요?
AdminLocalFileUploadController정도가 적당하겠네요!There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
추천 감사합니다! 원래는 한 메서드에 넣은 후 주석으로 명시해서 추후에 수정하는 방식으로 진행하려고했는데, 말씀해주신 방식이 더 좋을 것 같네요, 수정하도록 하겠습니다!