diff --git a/kotlin.MODULE.bazel b/kotlin.MODULE.bazel index 3c689fc9..3f00e849 100644 --- a/kotlin.MODULE.bazel +++ b/kotlin.MODULE.bazel @@ -23,4 +23,10 @@ maven.artifact(artifact = "protobuf-java", group = "com.google.protobuf", versio maven.artifact(artifact = "protobuf-kotlin", group = "com.google.protobuf", version = "4.30.2") maven.artifact(artifact = "javax.annotation-api", group = "javax.annotation", version = "1.3.2") +# Validation +maven.artifact(artifact = "spring-boot-starter-validation", group = "org.springframework.boot", version = "4.0.1") + +# AWS S3 +maven.artifact(artifact = "s3", group = "software.amazon.awssdk", version = "2.31.0") + use_repo(maven, "maven") diff --git a/systems/configuration/configuration-adapter-in/BUILD.bazel b/systems/configuration/configuration-adapter-in/BUILD.bazel index 40db9dd7..368ccfb8 100644 --- a/systems/configuration/configuration-adapter-in/BUILD.bazel +++ b/systems/configuration/configuration-adapter-in/BUILD.bazel @@ -17,5 +17,23 @@ kt_jvm_test( javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", test_class = "hs.kr.entrydsm.configuration.adapterin.ConfigurationAdapterInModuleTest", - deps = MODULE_DEPS + TEST_DEPS, + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "document_api_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.configuration.adapterin.common.DocumentApiContractTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "document_controller_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.configuration.adapterin.document.DocumentControllerTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], ) diff --git a/systems/configuration/configuration-adapter-in/deps.bzl b/systems/configuration/configuration-adapter-in/deps.bzl index aff538e0..ecb4c70c 100644 --- a/systems/configuration/configuration-adapter-in/deps.bzl +++ b/systems/configuration/configuration-adapter-in/deps.bzl @@ -7,6 +7,9 @@ KOTLIN_DEPS = [ "@maven//:com_google_protobuf_protobuf_kotlin", "@maven//:javax_annotation_javax_annotation_api", "@maven//:org_springframework_boot_spring_boot_starter", + "@maven//:org_springframework_boot_spring_boot_starter_web", + "@maven//:org_springframework_boot_spring_boot_starter_validation", + "@maven//:com_fasterxml_jackson_module_jackson_module_kotlin", "//contracts:configuration_grpc_java", "//contracts:configuration_java_proto", "//systems/configuration/configuration-domain:main", @@ -14,6 +17,7 @@ KOTLIN_DEPS = [ TEST_DEPS = [ "@maven//:junit_junit", + "@maven//:org_springframework_boot_spring_boot_starter_test", ] MODULE_DEPS = KOTLIN_DEPS diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ApiResponse.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ApiResponse.kt new file mode 100644 index 00000000..f63e9426 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ApiResponse.kt @@ -0,0 +1,32 @@ +package hs.kr.entrydsm.configuration.adapterin.common + +import com.fasterxml.jackson.annotation.JsonInclude +import java.time.Instant + +data class ApiResponse( + val success: Boolean, + val data: T? = null, + val error: ErrorResponse? = null, + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val timestamp: Instant? = null, +) { + companion object { + fun success(data: T) = ApiResponse(success = true, data = data) + + fun failure(errorCode: ErrorCode, message: String? = null) = ApiResponse( + success = false, + error = ErrorResponse( + code = errorCode.name, + message = message ?: errorCode.message, + status = errorCode.status.value(), + ), + timestamp = Instant.now(), + ) + } +} + +data class ErrorResponse( + val code: String, + val message: String, + val status: Int, +) diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentExceptionHandler.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentExceptionHandler.kt new file mode 100644 index 00000000..752ded9d --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentExceptionHandler.kt @@ -0,0 +1,73 @@ +package hs.kr.entrydsm.configuration.adapterin.common + +import hs.kr.entrydsm.configuration.adapterin.document.InvalidFileReferenceIdException +import hs.kr.entrydsm.configuration.domain.document.exception.FileDocumentNotFoundException +import hs.kr.entrydsm.configuration.domain.document.exception.FileTooLargeException +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException +import hs.kr.entrydsm.configuration.domain.document.exception.PresignFailedException +import hs.kr.entrydsm.configuration.domain.document.exception.StorageUnavailableException +import hs.kr.entrydsm.configuration.domain.document.exception.StorageUploadFailedException +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.multipart.MaxUploadSizeExceededException + +@RestControllerAdvice +class DocumentExceptionHandler { + + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(InvalidFileFormatException::class) + fun handleInvalidFileFormat(e: InvalidFileFormatException) = + respond(ErrorCode.INVALID_FILE_FORMAT, e) + + @ExceptionHandler(FileTooLargeException::class, MaxUploadSizeExceededException::class) + fun handleFileTooLarge(e: Exception) = + respond(ErrorCode.FILE_TOO_LARGE, e) + + @ExceptionHandler(FileDocumentNotFoundException::class) + fun handleFileNotFound(e: FileDocumentNotFoundException) = + respond(ErrorCode.FILE_NOT_FOUND, e) + + @ExceptionHandler( + InvalidFileNameException::class, + InvalidFileReferenceIdException::class, + MissingServletRequestParameterException::class, + MethodArgumentNotValidException::class, + MethodArgumentTypeMismatchException::class, + HttpMessageNotReadableException::class, + ) + fun handleInvalidRequestParam(e: Exception) = + respond(ErrorCode.INVALID_REQUEST_PARAM, e) + + @ExceptionHandler(StorageUploadFailedException::class) + fun handleStorageUploadFailed(e: StorageUploadFailedException) = + respond(ErrorCode.STORAGE_UPLOAD_FAILED, e) + + @ExceptionHandler(PresignFailedException::class) + fun handlePresignFailed(e: PresignFailedException) = + respond(ErrorCode.PRESIGN_FAILED, e) + + @ExceptionHandler(StorageUnavailableException::class) + fun handleStorageUnavailable(e: StorageUnavailableException) = + respond(ErrorCode.STORAGE_UNAVAILABLE, e) + + @ExceptionHandler(Exception::class) + fun handleUnexpected(e: Exception): ResponseEntity> { + log.error("Unhandled exception", e) + return ResponseEntity + .status(ErrorCode.INTERNAL_SERVER_ERROR.status) + .body(ApiResponse.failure(ErrorCode.INTERNAL_SERVER_ERROR)) + } + + private fun respond(errorCode: ErrorCode, e: Exception): ResponseEntity> { + log.warn("{}: {}", errorCode.name, e.message) + return ResponseEntity.status(errorCode.status).body(ApiResponse.failure(errorCode)) + } +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ErrorCode.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ErrorCode.kt new file mode 100644 index 00000000..cf5b72fc --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ErrorCode.kt @@ -0,0 +1,14 @@ +package hs.kr.entrydsm.configuration.adapterin.common + +import org.springframework.http.HttpStatus + +enum class ErrorCode(val status: HttpStatus, val message: String) { + INVALID_REQUEST_PARAM(HttpStatus.BAD_REQUEST, "요청 파라미터가 올바르지 않습니다."), + INVALID_FILE_FORMAT(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다."), + FILE_TOO_LARGE(HttpStatus.CONTENT_TOO_LARGE, "허용 용량을 초과했습니다."), + STORAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "파일 저장에 실패했습니다."), + PRESIGN_FAILED(HttpStatus.BAD_GATEWAY, "다운로드 URL 발급에 실패했습니다."), + STORAGE_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "파일 저장소에 접근할 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/AdmissionTicketController.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/AdmissionTicketController.kt new file mode 100644 index 00000000..c14fe1a8 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/AdmissionTicketController.kt @@ -0,0 +1,55 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.adapterin.common.ApiResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.DownloadUrlResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.UploadFileResponse +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileExtension +import hs.kr.entrydsm.configuration.domain.document.FileNaming +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.UploadFileUseCase +import org.springframework.web.bind.annotation.GetMapping +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.RestController +import org.springframework.web.multipart.MultipartFile + +private val CATEGORY = FileCategory.ADMISSION_TICKET + +@RestController +@RequestMapping("/api/document/v11/admission-ticket") +class AdmissionTicketController( + private val uploadFileUseCase: UploadFileUseCase, + private val issueDownloadUrlUseCase: IssueDownloadUrlUseCase, +) { + + @PostMapping + fun save( + @RequestParam("file") file: MultipartFile, + @RequestParam("receiptCode") receiptCode: String, + ): ApiResponse { + val fileName = FileNaming.admissionTicketFileName(receiptCode, file.requireExtension(CATEGORY)) + val saved = file.inputStream.use { + uploadFileUseCase.upload(file.toUploadCommand(CATEGORY, fileName), it) + } + return ApiResponse.success(UploadFileResponse.from(saved)) + } + + @GetMapping("/download") + fun download( + @RequestParam("receiptCode") receiptCode: String, + @RequestParam("format", defaultValue = "pdf") format: String, + ): ApiResponse { + val extension = FileExtension.fromExtension(format)?.takeIf(CATEGORY::supports) + ?: throw InvalidFileFormatException(format, CATEGORY) + val fileName = FileNaming.admissionTicketFileName(receiptCode, extension) + return ApiResponse.success( + DownloadUrlResponse.from( + issueDownloadUrlUseCase.issueByCommand(IssueDownloadUrlCommand(CATEGORY, fileName)) + ) + ) + } +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/ApplicantListController.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/ApplicantListController.kt new file mode 100644 index 00000000..c3730fb6 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/ApplicantListController.kt @@ -0,0 +1,57 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.adapterin.common.ApiResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.DownloadUrlResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.UploadFileResponse +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileExtension +import hs.kr.entrydsm.configuration.domain.document.FileNaming +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.UploadFileUseCase +import org.springframework.web.bind.annotation.GetMapping +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.RestController +import org.springframework.web.multipart.MultipartFile +import java.time.LocalDate + +private val CATEGORY = FileCategory.APPLICANT_LIST + +@RestController +@RequestMapping("/api/document/v11/applicant-list") +class ApplicantListController( + private val uploadFileUseCase: UploadFileUseCase, + private val issueDownloadUrlUseCase: IssueDownloadUrlUseCase, +) { + + @PostMapping + fun save( + @RequestParam("file") file: MultipartFile, + @RequestParam("fileName", required = false) fileName: String?, + ): ApiResponse { + file.requireExtension(CATEGORY) + val targetFileName = fileName?.also(::requireXlsx) + ?: FileNaming.applicantListFileName(LocalDate.now()) + val saved = file.inputStream.use { + uploadFileUseCase.upload(file.toUploadCommand(CATEGORY, targetFileName), it) + } + return ApiResponse.success(UploadFileResponse.from(saved)) + } + + @GetMapping("/download") + fun download(@RequestParam("fileName") fileName: String): ApiResponse = + ApiResponse.success( + DownloadUrlResponse.from( + issueDownloadUrlUseCase.issueByCommand(IssueDownloadUrlCommand(CATEGORY, fileName)) + ) + ) + + private fun requireXlsx(fileName: String) { + if (FileExtension.fromFileName(fileName) != FileExtension.XLSX) { + throw InvalidFileFormatException(fileName, CATEGORY) + } + } +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/ApplicationFileController.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/ApplicationFileController.kt new file mode 100644 index 00000000..0073b03c --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/ApplicationFileController.kt @@ -0,0 +1,81 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.adapterin.common.ApiResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.DownloadUrlResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.FileMetadataResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.UploadFileResponse +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileExtension +import hs.kr.entrydsm.configuration.domain.document.FileNaming +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.ReadFileUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.UploadFileUseCase +import org.springframework.web.bind.annotation.GetMapping +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.RestController +import org.springframework.web.multipart.MultipartFile +import java.time.Instant + +private val CATEGORY = FileCategory.APPLICATION + +@RestController +@RequestMapping("/api/document/v11/application") +class ApplicationFileController( + private val uploadFileUseCase: UploadFileUseCase, + private val issueDownloadUrlUseCase: IssueDownloadUrlUseCase, + private val readFileUseCase: ReadFileUseCase, +) { + + @PostMapping + fun save( + @RequestParam("file") file: MultipartFile, + @RequestParam("receiptCode") receiptCode: String, + ): ApiResponse { + val fileName = FileNaming.applicationFileName(receiptCode, file.requireExtension(CATEGORY)) + val saved = file.inputStream.use { + uploadFileUseCase.upload(file.toUploadCommand(CATEGORY, fileName), it) + } + return ApiResponse.success(UploadFileResponse.from(saved)) + } + + @GetMapping + fun find(@RequestParam("receiptCode") receiptCode: String): ApiResponse { + val stored = FileExtension.documentFormats + .mapNotNull { extension -> + readFileUseCase.findByFileName(CATEGORY, FileNaming.applicationFileName(receiptCode, extension)) + } + .maxByOrNull { it.createdAt ?: Instant.EPOCH } + if (stored != null) { + return ApiResponse.success( + FileMetadataResponse(key = stored.objectKey, fileName = stored.fileName, exists = true) + ) + } + val defaultFileName = FileNaming.applicationFileName(receiptCode, FileExtension.PDF) + return ApiResponse.success( + FileMetadataResponse( + key = CATEGORY.objectKeyOf(defaultFileName), + fileName = defaultFileName, + exists = false, + ) + ) + } + + @GetMapping("/download") + fun download( + @RequestParam("receiptCode") receiptCode: String, + @RequestParam("format", defaultValue = "pdf") format: String, + ): ApiResponse { + val extension = FileExtension.fromExtension(format)?.takeIf(CATEGORY::supports) + ?: throw InvalidFileFormatException(format, CATEGORY) + val fileName = FileNaming.applicationFileName(receiptCode, extension) + return ApiResponse.success( + DownloadUrlResponse.from( + issueDownloadUrlUseCase.issueByCommand(IssueDownloadUrlCommand(CATEGORY, fileName)) + ) + ) + } +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/AttachmentController.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/AttachmentController.kt new file mode 100644 index 00000000..8622013e --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/AttachmentController.kt @@ -0,0 +1,50 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.adapterin.common.ApiResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.DownloadUrlResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.UploadAttachmentResponse +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileNaming +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.UploadFileUseCase +import org.springframework.web.bind.annotation.GetMapping +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.RestController +import org.springframework.web.multipart.MultipartFile + +private val CATEGORY = FileCategory.ATTACHMENT + +@RestController +@RequestMapping("/api/document/v11/attachment") +class AttachmentController( + private val uploadFileUseCase: UploadFileUseCase, + private val issueDownloadUrlUseCase: IssueDownloadUrlUseCase, +) { + + @PostMapping + fun save(@RequestParam("file") file: MultipartFile): ApiResponse { + file.requireExtension(CATEGORY) + val fileName = FileNaming.attachmentFileName(file.originalFilename.orEmpty()) + val saved = file.inputStream.use { + uploadFileUseCase.upload(file.toUploadCommand(CATEGORY, fileName), it) + } + return ApiResponse.success( + UploadAttachmentResponse( + attachmentId = FileReferenceId.of(CATEGORY, requireNotNull(saved.id)), + key = saved.objectKey, + fileName = saved.originalName, + size = saved.sizeBytes, + ) + ) + } + + @GetMapping("/download") + fun download(@RequestParam("attachmentId") attachmentId: String): ApiResponse = + ApiResponse.success( + DownloadUrlResponse.from( + issueDownloadUrlUseCase.issueById(FileReferenceId.parse(CATEGORY, attachmentId)) + ) + ) +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/FileReferenceId.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/FileReferenceId.kt new file mode 100644 index 00000000..56f400b9 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/FileReferenceId.kt @@ -0,0 +1,21 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +class InvalidFileReferenceIdException(category: FileCategory, value: String) : + RuntimeException("Invalid ${category.name.lowercase()} id: $value") + +object FileReferenceId { + + fun of(category: FileCategory, id: Long): String = "${prefixOf(category)}$id" + + fun parse(category: FileCategory, value: String): Long { + val prefix = prefixOf(category) + return value.takeIf { it.startsWith(prefix) } + ?.removePrefix(prefix) + ?.toLongOrNull() + ?: throw InvalidFileReferenceIdException(category, value) + } + + private fun prefixOf(category: FileCategory) = "${category.name.lowercase()}_" +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/GuidelineController.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/GuidelineController.kt new file mode 100644 index 00000000..c3fff2bb --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/GuidelineController.kt @@ -0,0 +1,27 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.adapterin.common.ApiResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.DownloadUrlResponse +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +private val CATEGORY = FileCategory.GUIDELINE + +@RestController +@RequestMapping("/api/document/v11/guideline") +class GuidelineController( + private val issueDownloadUrlUseCase: IssueDownloadUrlUseCase, +) { + + @GetMapping("/download") + fun download(@RequestParam("guidelineId") guidelineId: String): ApiResponse = + ApiResponse.success( + DownloadUrlResponse.from( + issueDownloadUrlUseCase.issueById(FileReferenceId.parse(CATEGORY, guidelineId)) + ) + ) +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/MultipartFileExtensions.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/MultipartFileExtensions.kt new file mode 100644 index 00000000..c39ac3c1 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/MultipartFileExtensions.kt @@ -0,0 +1,20 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileExtension +import hs.kr.entrydsm.configuration.domain.document.command.UploadFileCommand +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import org.springframework.web.multipart.MultipartFile + +fun MultipartFile.requireExtension(category: FileCategory): FileExtension = + FileExtension.fromFileName(originalName())?.takeIf(category::supports) + ?: throw InvalidFileFormatException(originalName(), category) + +fun MultipartFile.toUploadCommand(category: FileCategory, fileName: String) = UploadFileCommand( + category = category, + originalName = originalName(), + fileName = fileName, + sizeBytes = size, +) + +private fun MultipartFile.originalName(): String = originalFilename.orEmpty() diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/PhotoController.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/PhotoController.kt new file mode 100644 index 00000000..cac8713f --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/PhotoController.kt @@ -0,0 +1,42 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.adapterin.common.ApiResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.UploadPhotoResponse +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileNaming +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.UploadFileUseCase +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.RestController +import org.springframework.web.multipart.MultipartFile + +private val CATEGORY = FileCategory.PHOTO + +@RestController +@RequestMapping("/api/document/v11/photo") +class PhotoController( + private val uploadFileUseCase: UploadFileUseCase, + private val issueDownloadUrlUseCase: IssueDownloadUrlUseCase, +) { + + @PostMapping + fun save(@RequestParam("file") file: MultipartFile): ApiResponse { + val fileName = FileNaming.photoFileName(file.requireExtension(CATEGORY)) + val saved = file.inputStream.use { + uploadFileUseCase.upload(file.toUploadCommand(CATEGORY, fileName), it) + } + val downloadUrl = issueDownloadUrlUseCase.issueByCommand( + IssueDownloadUrlCommand(CATEGORY, saved.fileName) + ) + return ApiResponse.success( + UploadPhotoResponse( + key = saved.objectKey, + fileName = saved.fileName, + url = downloadUrl.downloadUrl, + ) + ) + } +} diff --git a/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/dto/FileResponses.kt b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/dto/FileResponses.kt new file mode 100644 index 00000000..6941ebe1 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/dto/FileResponses.kt @@ -0,0 +1,49 @@ +package hs.kr.entrydsm.configuration.adapterin.document.dto + +import hs.kr.entrydsm.configuration.domain.document.DownloadUrl +import hs.kr.entrydsm.configuration.domain.document.FileDocument + +data class UploadFileResponse( + val key: String, + val fileName: String, +) { + companion object { + fun from(fileDocument: FileDocument) = UploadFileResponse( + key = fileDocument.objectKey, + fileName = fileDocument.fileName, + ) + } +} + +data class UploadPhotoResponse( + val key: String, + val fileName: String, + val url: String, +) + +data class UploadAttachmentResponse( + val attachmentId: String, + val key: String, + val fileName: String, + val size: Long, +) + +data class DownloadUrlResponse( + val fileName: String, + val downloadUrl: String, + val expiresIn: Long, +) { + companion object { + fun from(downloadUrl: DownloadUrl) = DownloadUrlResponse( + fileName = downloadUrl.fileName, + downloadUrl = downloadUrl.downloadUrl, + expiresIn = downloadUrl.expiresIn, + ) + } +} + +data class FileMetadataResponse( + val key: String, + val fileName: String, + val exists: Boolean, +) diff --git a/systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentApiContractTest.kt b/systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentApiContractTest.kt new file mode 100644 index 00000000..f3e26c03 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentApiContractTest.kt @@ -0,0 +1,205 @@ +package hs.kr.entrydsm.configuration.adapterin.common + +import hs.kr.entrydsm.configuration.adapterin.document.FileReferenceId +import hs.kr.entrydsm.configuration.adapterin.document.InvalidFileReferenceIdException +import hs.kr.entrydsm.configuration.adapterin.document.dto.DownloadUrlResponse +import hs.kr.entrydsm.configuration.adapterin.document.dto.UploadFileResponse +import hs.kr.entrydsm.configuration.adapterin.document.requireExtension +import hs.kr.entrydsm.configuration.adapterin.document.toUploadCommand +import hs.kr.entrydsm.configuration.domain.document.DownloadUrl +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import hs.kr.entrydsm.configuration.domain.document.FileExtension +import hs.kr.entrydsm.configuration.domain.document.exception.FileDocumentNotFoundException +import hs.kr.entrydsm.configuration.domain.document.exception.FileTooLargeException +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException +import hs.kr.entrydsm.configuration.domain.document.exception.PresignFailedException +import hs.kr.entrydsm.configuration.domain.document.exception.StorageUnavailableException +import hs.kr.entrydsm.configuration.domain.document.exception.StorageUploadFailedException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.springframework.http.HttpStatus +import org.springframework.mock.web.MockMultipartFile +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.multipart.MaxUploadSizeExceededException + +class DocumentApiContractTest { + + private val handler = DocumentExceptionHandler() + + @Test + fun `성공 응답은 data만 담고 error와 timestamp는 비운다`() { + val response = ApiResponse.success("ok") + + assertTrue(response.success) + assertEquals("ok", response.data) + assertNull(response.error) + assertNull(response.timestamp) + } + + @Test + fun `실패 응답은 코드 메시지 상태와 발생 시각을 담는다`() { + val response = ApiResponse.failure(ErrorCode.FILE_NOT_FOUND) + + assertEquals(false, response.success) + assertNull(response.data) + assertEquals("FILE_NOT_FOUND", response.error?.code) + assertEquals(ErrorCode.FILE_NOT_FOUND.message, response.error?.message) + assertEquals(HttpStatus.NOT_FOUND.value(), response.error?.status) + assertNotNull(response.timestamp) + } + + @Test + fun `실패 응답은 전달된 메시지로 기본 메시지를 대체한다`() { + val response = ApiResponse.failure(ErrorCode.INVALID_REQUEST_PARAM, "receiptCode가 필요합니다.") + + assertEquals("receiptCode가 필요합니다.", response.error?.message) + } + + @Test + fun `오류 코드마다 HTTP 상태가 고정된다`() { + assertEquals(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST_PARAM.status) + assertEquals(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_FILE_FORMAT.status) + assertEquals(HttpStatus.NOT_FOUND, ErrorCode.FILE_NOT_FOUND.status) + assertEquals(HttpStatus.CONTENT_TOO_LARGE, ErrorCode.FILE_TOO_LARGE.status) + assertEquals(HttpStatus.BAD_GATEWAY, ErrorCode.STORAGE_UPLOAD_FAILED.status) + assertEquals(HttpStatus.BAD_GATEWAY, ErrorCode.PRESIGN_FAILED.status) + assertEquals(HttpStatus.BAD_GATEWAY, ErrorCode.STORAGE_UNAVAILABLE.status) + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR.status) + } + + @Test + fun `도메인 예외를 정해진 상태와 오류 코드로 변환한다`() { + assertMapped( + ErrorCode.INVALID_FILE_FORMAT, + handler.handleInvalidFileFormat(InvalidFileFormatException("a.exe", FileCategory.APPLICATION)), + ) + assertMapped( + ErrorCode.FILE_TOO_LARGE, + handler.handleFileTooLarge(FileTooLargeException(20, 10)), + ) + assertMapped( + ErrorCode.FILE_TOO_LARGE, + handler.handleFileTooLarge(MaxUploadSizeExceededException(10)), + ) + assertMapped( + ErrorCode.FILE_NOT_FOUND, + handler.handleFileNotFound(FileDocumentNotFoundException("photo/none.jpg")), + ) + assertMapped( + ErrorCode.STORAGE_UPLOAD_FAILED, + handler.handleStorageUploadFailed(StorageUploadFailedException("photo/a.jpg")), + ) + assertMapped( + ErrorCode.PRESIGN_FAILED, + handler.handlePresignFailed(PresignFailedException("photo/a.jpg")), + ) + assertMapped( + ErrorCode.STORAGE_UNAVAILABLE, + handler.handleStorageUnavailable(StorageUnavailableException("photo/a.jpg")), + ) + } + + @Test + fun `요청 오류 예외만 400으로 묶는다`() { + assertMapped( + ErrorCode.INVALID_REQUEST_PARAM, + handler.handleInvalidRequestParam(InvalidFileNameException("../etc/passwd")), + ) + assertMapped( + ErrorCode.INVALID_REQUEST_PARAM, + handler.handleInvalidRequestParam(InvalidFileReferenceIdException(FileCategory.ATTACHMENT, "123")), + ) + assertMapped( + ErrorCode.INVALID_REQUEST_PARAM, + handler.handleInvalidRequestParam(MissingServletRequestParameterException("receiptCode", "String")), + ) + } + + @Test + fun `처리하지 못한 예외는 내부 메시지를 노출하지 않는다`() { + val response = handler.handleUnexpected(IllegalStateException("jdbc://user:password@db")) + + assertMapped(ErrorCode.INTERNAL_SERVER_ERROR, response) + assertEquals(ErrorCode.INTERNAL_SERVER_ERROR.message, response.body?.error?.message) + } + + @Test + fun `참조 ID는 카테고리 접두사를 붙이고 되돌린다`() { + val referenceId = FileReferenceId.of(FileCategory.ATTACHMENT, 42) + + assertEquals("attachment_42", referenceId) + assertEquals(42L, FileReferenceId.parse(FileCategory.ATTACHMENT, referenceId)) + } + + @Test(expected = InvalidFileReferenceIdException::class) + fun `접두사가 없는 참조 ID는 거부한다`() { + FileReferenceId.parse(FileCategory.ATTACHMENT, "42") + } + + @Test(expected = InvalidFileReferenceIdException::class) + fun `다른 카테고리 접두사의 참조 ID는 거부한다`() { + FileReferenceId.parse(FileCategory.ATTACHMENT, "guideline_42") + } + + @Test(expected = InvalidFileReferenceIdException::class) + fun `숫자가 아닌 참조 ID는 거부한다`() { + FileReferenceId.parse(FileCategory.ATTACHMENT, "attachment_abc") + } + + @Test + fun `업로드 파일의 확장자를 인식하고 커맨드로 변환한다`() { + val file = MockMultipartFile("file", "증명사진.JPEG", null, ByteArray(3)) + + assertEquals(FileExtension.JPG, file.requireExtension(FileCategory.PHOTO)) + + val command = file.toUploadCommand(FileCategory.PHOTO, "photo_1.jpg") + assertEquals(FileCategory.PHOTO, command.category) + assertEquals("증명사진.JPEG", command.originalName) + assertEquals("photo_1.jpg", command.fileName) + assertEquals(3L, command.sizeBytes) + } + + @Test(expected = InvalidFileFormatException::class) + fun `알 수 없는 확장자는 형식 오류로 거부한다`() { + MockMultipartFile("file", "malware.exe", null, ByteArray(1)) + .requireExtension(FileCategory.ATTACHMENT) + } + + @Test + fun `응답 DTO는 도메인 값을 그대로 옮긴다`() { + val fileDocument = FileDocument( + id = 7, + originalName = "지원서.pdf", + objectKey = "application/application_1001.pdf", + bucket = "entrydsm", + contentType = "application/pdf", + sizeBytes = 1024, + checksum = "abc", + ) + val uploaded = UploadFileResponse.from(fileDocument) + + assertEquals("application/application_1001.pdf", uploaded.key) + assertEquals(fileDocument.fileName, uploaded.fileName) + + val downloadUrl = DownloadUrlResponse.from( + DownloadUrl(fileName = "application_1001.pdf", downloadUrl = "https://s3/a", expiresIn = 300), + ) + assertEquals("application_1001.pdf", downloadUrl.fileName) + assertEquals("https://s3/a", downloadUrl.downloadUrl) + assertEquals(300L, downloadUrl.expiresIn) + } + + private fun assertMapped( + expected: ErrorCode, + response: org.springframework.http.ResponseEntity>, + ) { + assertEquals(expected.status, response.statusCode) + assertEquals(expected.name, response.body?.error?.code) + assertEquals(expected.status.value(), response.body?.error?.status) + } +} diff --git a/systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/document/DocumentControllerTest.kt b/systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/document/DocumentControllerTest.kt new file mode 100644 index 00000000..46057e20 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/document/DocumentControllerTest.kt @@ -0,0 +1,283 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.adapterin.common.DocumentExceptionHandler +import hs.kr.entrydsm.configuration.domain.document.DownloadUrl +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand +import hs.kr.entrydsm.configuration.domain.document.command.UploadFileCommand +import hs.kr.entrydsm.configuration.domain.document.exception.FileDocumentNotFoundException +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.ReadFileUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.UploadFileUseCase +import org.junit.Assert.assertEquals +import org.junit.Test +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.io.InputStream +import java.time.Instant +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class DocumentControllerTest { + + private val upload = RecordingUploadFileUseCase() + private val issue = StubIssueDownloadUrlUseCase() + private val read = StubReadFileUseCase() + + private fun mockMvc(controller: Any): MockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(DocumentExceptionHandler()) + .build() + + private fun pdf(name: String = "지원서.pdf") = + MockMultipartFile("file", name, null, "content".toByteArray()) + + @Test + fun `지원서 업로드는 수험번호 기반 파일명으로 저장한다`() { + mockMvc(applicationController()) + .perform(multipart("/api/document/v11/application").file(pdf()).param("receiptCode", "1001")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.fileName").value("application_1001.pdf")) + .andExpect(jsonPath("$.data.key").value("application/application_1001.pdf")) + + assertEquals(FileCategory.APPLICATION, upload.lastCommand?.category) + assertEquals("application_1001.pdf", upload.lastCommand?.fileName) + assertEquals("지원서.pdf", upload.lastCommand?.originalName) + } + + @Test + fun `지원서 업로드는 허용하지 않는 형식을 400으로 거부한다`() { + mockMvc(applicationController()) + .perform( + multipart("/api/document/v11/application") + .file(MockMultipartFile("file", "지원서.jpg", null, ByteArray(1))) + .param("receiptCode", "1001"), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("INVALID_FILE_FORMAT")) + } + + @Test + fun `지원서 조회는 같은 수험번호의 여러 형식 중 최근 업로드본을 돌려준다`() { + read.put(stored("application/application_1001.pdf", Instant.parse("2026-01-01T00:00:00Z"))) + read.put(stored("application/application_1001.hwp", Instant.parse("2026-02-01T00:00:00Z"))) + + mockMvc(applicationController()) + .perform(get("/api/document/v11/application").param("receiptCode", "1001")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.exists").value(true)) + .andExpect(jsonPath("$.data.fileName").value("application_1001.hwp")) + } + + @Test + fun `지원서가 없으면 pdf 기본 파일명과 미존재 표시를 돌려준다`() { + mockMvc(applicationController()) + .perform(get("/api/document/v11/application").param("receiptCode", "1001")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.exists").value(false)) + .andExpect(jsonPath("$.data.fileName").value("application_1001.pdf")) + .andExpect(jsonPath("$.data.key").value("application/application_1001.pdf")) + } + + @Test + fun `지원서 다운로드는 요청한 형식의 파일로 URL을 발급한다`() { + mockMvc(applicationController()) + .perform( + get("/api/document/v11/application/download") + .param("receiptCode", "1001") + .param("format", "hwp"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.fileName").value("application_1001.hwp")) + .andExpect(jsonPath("$.data.expiresIn").value(300)) + + assertEquals("application_1001.hwp", issue.lastCommand?.fileName) + } + + @Test + fun `지원하지 않는 다운로드 형식은 400으로 거부한다`() { + mockMvc(applicationController()) + .perform( + get("/api/document/v11/application/download") + .param("receiptCode", "1001") + .param("format", "jpg"), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("INVALID_FILE_FORMAT")) + } + + @Test + fun `수험표 다운로드는 수험번호 기반 파일명을 사용한다`() { + mockMvc(AdmissionTicketController(upload, issue)) + .perform(get("/api/document/v11/admission-ticket/download").param("receiptCode", "1001")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.fileName").value("admission_ticket_1001.pdf")) + + assertEquals(FileCategory.ADMISSION_TICKET, issue.lastCommand?.category) + } + + @Test + fun `수험번호에 경로 문자가 들어오면 400으로 거부한다`() { + mockMvc(AdmissionTicketController(upload, issue)) + .perform(get("/api/document/v11/admission-ticket/download").param("receiptCode", "../1001")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("INVALID_REQUEST_PARAM")) + } + + @Test + fun `지원자 명단은 파일명을 주지 않으면 오늘 날짜로 저장한다`() { + val expected = "applicants_${DateTimeFormatter.ofPattern("yyyyMMdd").format(LocalDate.now())}.xlsx" + + mockMvc(ApplicantListController(upload, issue)) + .perform(multipart("/api/document/v11/applicant-list").file(xlsx())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.fileName").value(expected)) + } + + @Test + fun `지원자 명단은 지정한 xlsx 파일명을 그대로 쓴다`() { + mockMvc(ApplicantListController(upload, issue)) + .perform( + multipart("/api/document/v11/applicant-list") + .file(xlsx()) + .param("fileName", "applicants_20260726.xlsx"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.fileName").value("applicants_20260726.xlsx")) + } + + @Test + fun `지원자 명단은 xlsx가 아닌 파일명을 400으로 거부한다`() { + mockMvc(ApplicantListController(upload, issue)) + .perform( + multipart("/api/document/v11/applicant-list") + .file(xlsx()) + .param("fileName", "applicants.csv"), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("INVALID_FILE_FORMAT")) + } + + @Test + fun `첨부파일 업로드는 카테고리 접두사가 붙은 ID를 돌려준다`() { + mockMvc(AttachmentController(upload, issue)) + .perform(multipart("/api/document/v11/attachment").file(pdf("첨부.pdf"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.attachmentId").value("attachment_7")) + .andExpect(jsonPath("$.data.fileName").value("첨부.pdf")) + .andExpect(jsonPath("$.data.size").value(7)) + } + + @Test + fun `첨부파일 다운로드는 접두사가 붙은 ID만 받는다`() { + val mvc = mockMvc(AttachmentController(upload, issue)) + + mvc.perform(get("/api/document/v11/attachment/download").param("attachmentId", "attachment_7")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.downloadUrl").value("https://s3/id/7")) + + mvc.perform(get("/api/document/v11/attachment/download").param("attachmentId", "7")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("INVALID_REQUEST_PARAM")) + } + + @Test + fun `입학요강 다운로드는 guideline 접두사 ID를 파싱한다`() { + mockMvc(GuidelineController(issue)) + .perform(get("/api/document/v11/guideline/download").param("guidelineId", "guideline_3")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.downloadUrl").value("https://s3/id/3")) + } + + @Test + fun `증명사진 업로드는 저장 직후 다운로드 URL을 함께 돌려준다`() { + mockMvc(PhotoController(upload, issue)) + .perform(multipart("/api/document/v11/photo").file(MockMultipartFile("file", "사진.png", null, ByteArray(2)))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.url").value("https://s3/photo")) + + assertEquals(FileCategory.PHOTO, upload.lastCommand?.category) + assert(upload.lastCommand!!.fileName.matches(Regex("photo_[0-9a-f]{32}\\.png"))) + } + + @Test + fun `없는 파일을 조회하면 404를 돌려준다`() { + issue.notFound = true + + mockMvc(GuidelineController(issue)) + .perform(get("/api/document/v11/guideline/download").param("guidelineId", "guideline_3")) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.error.code").value("FILE_NOT_FOUND")) + } + + private fun applicationController() = ApplicationFileController(upload, issue, read) + + private fun xlsx() = MockMultipartFile("file", "명단.xlsx", null, ByteArray(1)) + + private fun stored(objectKey: String, createdAt: Instant) = FileDocument( + id = 7, + originalName = "지원서", + objectKey = objectKey, + bucket = "entrydsm", + contentType = "application/pdf", + sizeBytes = 7, + checksum = "abc", + createdAt = createdAt, + ) + + private class RecordingUploadFileUseCase : UploadFileUseCase { + var lastCommand: UploadFileCommand? = null + + override fun upload(command: UploadFileCommand, content: InputStream): FileDocument { + lastCommand = command + return FileDocument( + id = 7, + originalName = command.originalName, + objectKey = command.category.objectKeyOf(command.fileName), + bucket = "entrydsm", + contentType = "application/octet-stream", + sizeBytes = command.sizeBytes, + checksum = "abc", + createdAt = Instant.parse("2026-01-01T00:00:00Z"), + ) + } + } + + private class StubIssueDownloadUrlUseCase : IssueDownloadUrlUseCase { + var lastCommand: IssueDownloadUrlCommand? = null + var notFound = false + + override fun issueByCommand(command: IssueDownloadUrlCommand): DownloadUrl { + lastCommand = command + if (notFound) throw FileDocumentNotFoundException(command.fileName) + return DownloadUrl(command.fileName, "https://s3/photo", 300) + } + + override fun issueById(id: Long): DownloadUrl { + if (notFound) throw FileDocumentNotFoundException("id=$id") + return DownloadUrl("file_$id.pdf", "https://s3/id/$id", 300) + } + } + + private class StubReadFileUseCase : ReadFileUseCase { + private val byObjectKey = mutableMapOf() + + fun put(fileDocument: FileDocument) { + byObjectKey[fileDocument.objectKey] = fileDocument + } + + override fun findById(id: Long): FileDocument = throw FileDocumentNotFoundException("id=$id") + + override fun findByFileName(category: FileCategory, fileName: String): FileDocument? = + byObjectKey[category.objectKeyOf(fileName)] + + override fun existsById(id: Long): Boolean = false + } +} diff --git a/systems/configuration/configuration-adapter-out/BUILD.bazel b/systems/configuration/configuration-adapter-out/BUILD.bazel index a6e0ec23..086dc0b3 100644 --- a/systems/configuration/configuration-adapter-out/BUILD.bazel +++ b/systems/configuration/configuration-adapter-out/BUILD.bazel @@ -17,5 +17,23 @@ kt_jvm_test( javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", test_class = "hs.kr.entrydsm.configuration.adapterout.ConfigurationAdapterOutModuleTest", - deps = MODULE_DEPS + TEST_DEPS, + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "s3_storage_adapter_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.configuration.adapterout.S3StorageAdapterTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "file_document_persistence_adapter_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.configuration.adapterout.FileDocumentPersistenceAdapterTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], ) diff --git a/systems/configuration/configuration-adapter-out/deps.bzl b/systems/configuration/configuration-adapter-out/deps.bzl index 26e291c1..5e4fffce 100644 --- a/systems/configuration/configuration-adapter-out/deps.bzl +++ b/systems/configuration/configuration-adapter-out/deps.bzl @@ -1,6 +1,7 @@ KOTLIN_DEPS = [ "@maven//:org_springframework_boot_spring_boot_starter_data_jpa", "@maven//:com_mysql_mysql_connector_j", + "@maven//:software_amazon_awssdk_s3", "//systems/configuration/configuration-domain:main", ] diff --git a/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt new file mode 100644 index 00000000..af4c0071 --- /dev/null +++ b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt @@ -0,0 +1,37 @@ +package hs.kr.entrydsm.configuration.adapterout + +import hs.kr.entrydsm.configuration.adapterout.entity.FileDocumentJpaEntity +import hs.kr.entrydsm.configuration.adapterout.repository.FileDocumentJpaRepository +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import hs.kr.entrydsm.configuration.domain.document.port.out.FileDocumentRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Transactional(readOnly = true) +class FileDocumentPersistenceAdapter( + private val fileDocumentJpaRepository: FileDocumentJpaRepository, +) : FileDocumentRepository { + + @Transactional + override fun save(fileDocument: FileDocument): FileDocument { + // object_key 가 고유하므로 같은 키를 다시 올리면 새 행 대신 기존 행을 갱신한다. + val id = fileDocument.id ?: fileDocumentJpaRepository.findByObjectKey(fileDocument.objectKey)?.id + return fileDocumentJpaRepository.save( + FileDocumentJpaEntity.from(fileDocument.copy(id = id)) + ).toDomain() + } + + override fun findById(id: Long): FileDocument? = + fileDocumentJpaRepository.findById(id).orElse(null)?.toDomain() + + override fun findByObjectKey(objectKey: String): FileDocument? = + fileDocumentJpaRepository.findByObjectKey(objectKey)?.toDomain() + + override fun existsById(id: Long): Boolean = + fileDocumentJpaRepository.existsById(id) + + @Transactional + override fun deleteByObjectKey(objectKey: String) = + fileDocumentJpaRepository.deleteByObjectKey(objectKey) +} diff --git a/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt new file mode 100644 index 00000000..56163742 --- /dev/null +++ b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt @@ -0,0 +1,107 @@ +package hs.kr.entrydsm.configuration.adapterout + +import hs.kr.entrydsm.configuration.domain.document.StoredObject +import hs.kr.entrydsm.configuration.domain.document.exception.PresignFailedException +import hs.kr.entrydsm.configuration.domain.document.exception.StorageUnavailableException +import hs.kr.entrydsm.configuration.domain.document.exception.StorageUploadFailedException +import hs.kr.entrydsm.configuration.domain.document.port.out.StoragePort +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import software.amazon.awssdk.core.exception.SdkException +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.HeadObjectRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.model.S3Exception +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import java.io.InputStream +import java.time.Duration + +@Component +class S3StorageAdapter( + private val s3Client: S3Client, + private val s3Presigner: S3Presigner, + @Value("\${aws.s3.bucket}") private val bucket: String, +) : StoragePort { + + override fun upload( + objectKey: String, + contentType: String, + sizeBytes: Long, + content: InputStream, + ): StoredObject { + val response = try { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .contentLength(sizeBytes) + .checksumAlgorithm(ChecksumAlgorithm.SHA256) + .build(), + RequestBody.fromInputStream(content, sizeBytes), + ) + } catch (e: SdkException) { + throw StorageUploadFailedException(objectKey, e) + } + return StoredObject( + bucket = bucket, + objectKey = objectKey, + checksum = response.checksumSHA256() ?: response.eTag().orEmpty().trim('"'), + ) + } + + override fun issueDownloadUrl(objectKey: String, expiresInSeconds: Long): String = + try { + s3Presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expiresInSeconds)) + .getObjectRequest( + GetObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build() + ) + .build() + ).url().toString() + } catch (e: SdkException) { + throw PresignFailedException(objectKey, e) + } + + override fun exists(objectKey: String): Boolean = + try { + s3Client.headObject( + HeadObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build() + ) + true + } catch (e: S3Exception) { + // HEAD 응답에는 본문이 없어 객체 없음이 NoSuchKeyException 대신 404 S3Exception 으로 올라오기도 한다. + if (e.statusCode() == HTTP_NOT_FOUND) false else throw StorageUnavailableException(objectKey, e) + } catch (e: SdkException) { + throw StorageUnavailableException(objectKey, e) + } + + override fun delete(objectKey: String) { + try { + s3Client.deleteObject( + DeleteObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build() + ) + } catch (e: SdkException) { + throw StorageUnavailableException(objectKey, e) + } + } + + private companion object { + const val HTTP_NOT_FOUND = 404 + } +} diff --git a/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt new file mode 100644 index 00000000..89155ffe --- /dev/null +++ b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt @@ -0,0 +1,26 @@ +package hs.kr.entrydsm.configuration.adapterout.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +@Configuration +class S3Config( + @Value("\${aws.s3.region:ap-northeast-2}") private val region: String, +) { + + @Bean + fun s3Client(): S3Client = + S3Client.builder() + .region(Region.of(region)) + .build() + + @Bean + fun s3Presigner(): S3Presigner = + S3Presigner.builder() + .region(Region.of(region)) + .build() +} diff --git a/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt new file mode 100644 index 00000000..a1582308 --- /dev/null +++ b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt @@ -0,0 +1,63 @@ +package hs.kr.entrydsm.configuration.adapterout.entity + +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant + +@Entity +@Table(name = "files") +class FileDocumentJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "original_name", nullable = false, length = 255) + val originalName: String, + + @Column(name = "object_key", unique = true, nullable = false, length = 255) + val objectKey: String, + + @Column(name = "bucket", nullable = false, length = 100) + val bucket: String, + + @Column(name = "content_type", nullable = false, length = 100) + val contentType: String, + + @Column(name = "size_bytes", nullable = false) + val sizeBytes: Long, + + @Column(name = "checksum", nullable = false, length = 64) + val checksum: String, + + @Column(name = "created_at", nullable = false) + val createdAt: Instant, +) { + fun toDomain() = FileDocument( + id = id, + originalName = originalName, + objectKey = objectKey, + bucket = bucket, + contentType = contentType, + sizeBytes = sizeBytes, + checksum = checksum, + createdAt = createdAt, + ) + + companion object { + fun from(domain: FileDocument) = FileDocumentJpaEntity( + id = domain.id, + originalName = domain.originalName, + objectKey = domain.objectKey, + bucket = domain.bucket, + contentType = domain.contentType, + sizeBytes = domain.sizeBytes, + checksum = domain.checksum, + createdAt = domain.createdAt ?: Instant.now(), + ) + } +} diff --git a/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt new file mode 100644 index 00000000..51a94f79 --- /dev/null +++ b/systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.configuration.adapterout.repository + +import hs.kr.entrydsm.configuration.adapterout.entity.FileDocumentJpaEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface FileDocumentJpaRepository : JpaRepository { + fun findByObjectKey(objectKey: String): FileDocumentJpaEntity? + fun deleteByObjectKey(objectKey: String) +} diff --git a/systems/configuration/configuration-adapter-out/src/test/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapterTest.kt b/systems/configuration/configuration-adapter-out/src/test/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapterTest.kt new file mode 100644 index 00000000..429b66c6 --- /dev/null +++ b/systems/configuration/configuration-adapter-out/src/test/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapterTest.kt @@ -0,0 +1,85 @@ +package hs.kr.entrydsm.configuration.adapterout + +import hs.kr.entrydsm.configuration.adapterout.entity.FileDocumentJpaEntity +import hs.kr.entrydsm.configuration.adapterout.repository.FileDocumentJpaRepository +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.lang.reflect.Proxy +import java.time.Instant + +class FileDocumentPersistenceAdapterTest { + + @Test + fun `같은 객체 키가 이미 있으면 기존 행을 갱신한다`() { + val saved = mutableListOf() + val adapter = FileDocumentPersistenceAdapter(repository(existing = entity(id = 7L), saved = saved)) + + val result = adapter.save(document()) + + assertEquals(7L, saved.single().id) + assertEquals(7L, result.id) + } + + @Test + fun `객체 키가 없으면 새 행으로 저장한다`() { + val saved = mutableListOf() + val adapter = FileDocumentPersistenceAdapter(repository(existing = null, saved = saved)) + + adapter.save(document()) + + assertNull(saved.single().id) + } + + @Test + fun `도메인 필드가 엔티티로 그대로 옮겨진다`() { + val saved = mutableListOf() + val adapter = FileDocumentPersistenceAdapter(repository(existing = null, saved = saved)) + + val result = adapter.save(document()) + + assertEquals("원본.pdf", result.originalName) + assertEquals("application/application_1234.pdf", result.objectKey) + assertEquals("entrydsm", result.bucket) + assertEquals("application/pdf", result.contentType) + assertEquals(1024L, result.sizeBytes) + assertEquals("sha256", result.checksum) + } + + private fun document() = FileDocument( + originalName = "원본.pdf", + objectKey = "application/application_1234.pdf", + bucket = "entrydsm", + contentType = "application/pdf", + sizeBytes = 1024L, + checksum = "sha256", + ) + + private fun entity(id: Long?) = FileDocumentJpaEntity( + id = id, + originalName = "원본.pdf", + objectKey = "application/application_1234.pdf", + bucket = "entrydsm", + contentType = "application/pdf", + sizeBytes = 1024L, + checksum = "sha256", + createdAt = Instant.EPOCH, + ) + + // JpaRepository 상속 메서드가 많아 프록시로 필요한 두 개만 응답한다. + private fun repository( + existing: FileDocumentJpaEntity?, + saved: MutableList, + ): FileDocumentJpaRepository = + Proxy.newProxyInstance( + FileDocumentJpaRepository::class.java.classLoader, + arrayOf(FileDocumentJpaRepository::class.java), + ) { _, method, args -> + when (method.name) { + "findByObjectKey" -> existing + "save" -> (args[0] as FileDocumentJpaEntity).also { saved += it } + else -> throw UnsupportedOperationException(method.name) + } + } as FileDocumentJpaRepository +} diff --git a/systems/configuration/configuration-adapter-out/src/test/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapterTest.kt b/systems/configuration/configuration-adapter-out/src/test/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapterTest.kt new file mode 100644 index 00000000..8eaeb314 --- /dev/null +++ b/systems/configuration/configuration-adapter-out/src/test/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapterTest.kt @@ -0,0 +1,93 @@ +package hs.kr.entrydsm.configuration.adapterout + +import hs.kr.entrydsm.configuration.domain.document.exception.StorageUnavailableException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest +import software.amazon.awssdk.services.s3.model.DeleteObjectResponse +import software.amazon.awssdk.services.s3.model.HeadObjectRequest +import software.amazon.awssdk.services.s3.model.HeadObjectResponse +import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import software.amazon.awssdk.services.s3.model.S3Exception +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import java.lang.reflect.Proxy + +class S3StorageAdapterTest { + + @Test + fun `객체가 있으면 존재한다고 판단한다`() { + assertTrue(adapter(FakeS3Client()).exists("photo/a.jpg")) + } + + @Test + fun `본문 없는 404 응답도 객체 없음으로 처리한다`() { + val client = FakeS3Client(headFailure = s3Exception(404)) + + assertFalse(adapter(client).exists("photo/a.jpg")) + } + + @Test + fun `NoSuchKey 예외도 객체 없음으로 처리한다`() { + val client = FakeS3Client(headFailure = NoSuchKeyException.builder().statusCode(404).build()) + + assertFalse(adapter(client).exists("photo/a.jpg")) + } + + @Test(expected = StorageUnavailableException::class) + fun `403 응답은 스토리지 오류로 올린다`() { + adapter(FakeS3Client(headFailure = s3Exception(403))).exists("photo/a.jpg") + } + + @Test(expected = StorageUnavailableException::class) + fun `네트워크 오류는 스토리지 오류로 올린다`() { + val client = FakeS3Client(headFailure = SdkClientException.builder().message("connect timed out").build()) + + adapter(client).exists("photo/a.jpg") + } + + @Test(expected = StorageUnavailableException::class) + fun `삭제 실패도 스토리지 오류로 올린다`() { + val client = FakeS3Client(deleteFailure = s3Exception(500)) + + adapter(client).delete("photo/a.jpg") + } + + @Test + fun `삭제가 성공하면 예외를 던지지 않는다`() { + adapter(FakeS3Client()).delete("photo/a.jpg") + } + + private fun adapter(client: S3Client) = S3StorageAdapter(client, stubPresigner(), "entrydsm") + + // presign 은 이 테스트에서 쓰지 않는다. 메서드가 7개라 프록시로 대신한다. + private fun stubPresigner(): S3Presigner = + Proxy.newProxyInstance( + S3Presigner::class.java.classLoader, + arrayOf(S3Presigner::class.java), + ) { _, _, _ -> throw UnsupportedOperationException() } as S3Presigner + + private fun s3Exception(statusCode: Int): S3Exception = + S3Exception.builder().statusCode(statusCode).message("status=$statusCode").build() as S3Exception + + private class FakeS3Client( + private val headFailure: RuntimeException? = null, + private val deleteFailure: RuntimeException? = null, + ) : S3Client { + override fun serviceName(): String = "s3" + + override fun close() = Unit + + override fun headObject(request: HeadObjectRequest): HeadObjectResponse { + headFailure?.let { throw it } + return HeadObjectResponse.builder().build() + } + + override fun deleteObject(request: DeleteObjectRequest): DeleteObjectResponse { + deleteFailure?.let { throw it } + return DeleteObjectResponse.builder().build() + } + } +} diff --git a/systems/configuration/configuration-application/BUILD.bazel b/systems/configuration/configuration-application/BUILD.bazel index 49cc84c2..eead0852 100644 --- a/systems/configuration/configuration-application/BUILD.bazel +++ b/systems/configuration/configuration-application/BUILD.bazel @@ -17,5 +17,14 @@ kt_jvm_test( javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", test_class = "hs.kr.entrydsm.configuration.application.ConfigurationApplicationModuleTest", - deps = MODULE_DEPS + TEST_DEPS, + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "file_document_service_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.configuration.application.FileDocumentServiceTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], ) diff --git a/systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt b/systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt new file mode 100644 index 00000000..21f929c5 --- /dev/null +++ b/systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt @@ -0,0 +1,101 @@ +package hs.kr.entrydsm.configuration.application + +import hs.kr.entrydsm.configuration.domain.document.DownloadUrl +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import hs.kr.entrydsm.configuration.domain.document.FileExtension +import hs.kr.entrydsm.configuration.domain.document.FileNaming +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand +import hs.kr.entrydsm.configuration.domain.document.command.UploadFileCommand +import hs.kr.entrydsm.configuration.domain.document.exception.FileDocumentNotFoundException +import hs.kr.entrydsm.configuration.domain.document.exception.FileTooLargeException +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import hs.kr.entrydsm.configuration.domain.document.port.`in`.IssueDownloadUrlUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.ReadFileUseCase +import hs.kr.entrydsm.configuration.domain.document.port.`in`.UploadFileUseCase +import hs.kr.entrydsm.configuration.domain.document.port.out.FileDocumentRepository +import hs.kr.entrydsm.configuration.domain.document.port.out.StoragePort +import org.slf4j.LoggerFactory +import java.io.InputStream + +class FileDocumentService( + private val storagePort: StoragePort, + private val fileDocumentRepository: FileDocumentRepository, + private val presignExpirySeconds: Long, +) : UploadFileUseCase, + IssueDownloadUrlUseCase, + ReadFileUseCase { + + private val log = LoggerFactory.getLogger(javaClass) + + override fun upload(command: UploadFileCommand, content: InputStream): FileDocument { + val extension = resolveExtension(command) + if (command.category.exceedsMaxSize(command.sizeBytes)) { + throw FileTooLargeException(command.sizeBytes, command.category.maxSizeBytes) + } + + val objectKey = command.category.objectKeyOf(command.fileName) + // 같은 키를 덮어쓴 경우 보상 삭제가 이전 파일까지 지우면 안 된다. + val replacedExistingObject = storagePort.exists(objectKey) + val stored = storagePort.upload(objectKey, extension.contentType, command.sizeBytes, content) + + return try { + fileDocumentRepository.save( + FileDocument( + originalName = command.originalName, + objectKey = stored.objectKey, + bucket = stored.bucket, + contentType = extension.contentType, + sizeBytes = command.sizeBytes, + checksum = stored.checksum, + ) + ) + } catch (e: RuntimeException) { + if (!replacedExistingObject) deleteOrphan(objectKey) + throw e + } + } + + override fun issueByCommand(command: IssueDownloadUrlCommand): DownloadUrl { + val fileName = FileNaming.requireSafeFileName(command.fileName) + val objectKey = command.category.objectKeyOf(fileName) + if (!storagePort.exists(objectKey)) throw FileDocumentNotFoundException(objectKey) + return DownloadUrl( + fileName = fileName, + downloadUrl = storagePort.issueDownloadUrl(objectKey, presignExpirySeconds), + expiresIn = presignExpirySeconds, + ) + } + + override fun issueById(id: Long): DownloadUrl { + val fileDocument = findById(id) + return DownloadUrl( + fileName = fileDocument.originalName, + downloadUrl = storagePort.issueDownloadUrl(fileDocument.objectKey, presignExpirySeconds), + expiresIn = presignExpirySeconds, + ) + } + + override fun findById(id: Long): FileDocument = + fileDocumentRepository.findById(id) ?: throw FileDocumentNotFoundException("id=$id") + + override fun findByFileName(category: FileCategory, fileName: String): FileDocument? = + fileDocumentRepository.findByObjectKey(category.objectKeyOf(fileName)) + + override fun existsById(id: Long): Boolean = + fileDocumentRepository.existsById(id) + + private fun resolveExtension(command: UploadFileCommand): FileExtension { + val extension = FileExtension.fromFileName(command.originalName) + ?: throw InvalidFileFormatException(command.originalName, command.category) + if (!command.category.supports(extension)) { + throw InvalidFileFormatException(command.originalName, command.category) + } + return extension + } + + private fun deleteOrphan(objectKey: String) { + runCatching { storagePort.delete(objectKey) } + .onFailure { log.warn("Failed to delete orphaned object: {}", objectKey, it) } + } +} diff --git a/systems/configuration/configuration-application/src/test/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentServiceTest.kt b/systems/configuration/configuration-application/src/test/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentServiceTest.kt new file mode 100644 index 00000000..c70b27c0 --- /dev/null +++ b/systems/configuration/configuration-application/src/test/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentServiceTest.kt @@ -0,0 +1,203 @@ +package hs.kr.entrydsm.configuration.application + +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import hs.kr.entrydsm.configuration.domain.document.StoredObject +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand +import hs.kr.entrydsm.configuration.domain.document.command.UploadFileCommand +import hs.kr.entrydsm.configuration.domain.document.exception.FileDocumentNotFoundException +import hs.kr.entrydsm.configuration.domain.document.exception.FileTooLargeException +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileFormatException +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException +import hs.kr.entrydsm.configuration.domain.document.port.out.FileDocumentRepository +import hs.kr.entrydsm.configuration.domain.document.port.out.StoragePort +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.InputStream + +class FileDocumentServiceTest { + + private val storage = FakeStoragePort() + private val repository = FakeFileDocumentRepository() + private val service = FileDocumentService(storage, repository, presignExpirySeconds = 300) + + @Test + fun `업로드는 카테고리 키로 저장하고 저장된 메타데이터를 돌려준다`() { + val saved = service.upload(command(), content()) + + assertEquals("application/application_1001.pdf", saved.objectKey) + assertEquals("application/pdf", saved.contentType) + assertEquals("지원서.pdf", saved.originalName) + assertEquals(listOf("application/application_1001.pdf"), storage.uploaded) + assertEquals(1, repository.saved.size) + } + + @Test(expected = InvalidFileFormatException::class) + fun `카테고리가 허용하지 않는 확장자는 거부한다`() { + service.upload(command(originalName = "사진.jpg"), content()) + } + + @Test(expected = InvalidFileFormatException::class) + fun `확장자가 없으면 거부한다`() { + service.upload(command(originalName = "지원서"), content()) + } + + @Test(expected = FileTooLargeException::class) + fun `카테고리 용량 한도를 넘으면 거부한다`() { + service.upload(command(sizeBytes = FileCategory.APPLICATION.maxSizeBytes + 1), content()) + } + + @Test(expected = InvalidFileNameException::class) + fun `파일명에 상위 경로 참조가 들어오면 거부한다`() { + service.upload(command(fileName = "../../etc/passwd"), content()) + } + + @Test + fun `메타데이터 저장이 실패하면 새로 올린 객체를 지운다`() { + repository.failOnSave = true + + runCatching { service.upload(command(), content()) } + + assertEquals(listOf("application/application_1001.pdf"), storage.deleted) + } + + @Test + fun `덮어쓴 객체는 메타데이터 저장이 실패해도 지우지 않는다`() { + storage.existingKeys += "application/application_1001.pdf" + repository.failOnSave = true + + runCatching { service.upload(command(), content()) } + + assertTrue(storage.deleted.isEmpty()) + } + + @Test + fun `보상 삭제가 실패해도 원래 예외를 그대로 올린다`() { + repository.failOnSave = true + storage.failOnDelete = true + + val error = runCatching { service.upload(command(), content()) }.exceptionOrNull() + + assertEquals("save failed", error?.message) + } + + @Test + fun `파일명으로 다운로드 URL을 발급한다`() { + storage.existingKeys += "application/application_1001.pdf" + + val downloadUrl = service.issueByCommand( + IssueDownloadUrlCommand(FileCategory.APPLICATION, "application_1001.pdf"), + ) + + assertEquals("application_1001.pdf", downloadUrl.fileName) + assertEquals("https://s3/application/application_1001.pdf?expires=300", downloadUrl.downloadUrl) + assertEquals(300L, downloadUrl.expiresIn) + } + + @Test(expected = FileDocumentNotFoundException::class) + fun `없는 객체의 다운로드 URL은 발급하지 않는다`() { + service.issueByCommand(IssueDownloadUrlCommand(FileCategory.APPLICATION, "application_1001.pdf")) + } + + @Test + fun `ID로 다운로드 URL을 발급하면 원본 파일명을 돌려준다`() { + repository.saved += FileDocument( + id = 1, + originalName = "첨부.pdf", + objectKey = "attachment/abc_첨부.pdf", + bucket = "entrydsm", + contentType = "application/pdf", + sizeBytes = 10, + checksum = "abc", + ) + + val downloadUrl = service.issueById(1) + + assertEquals("첨부.pdf", downloadUrl.fileName) + assertTrue(downloadUrl.downloadUrl.startsWith("https://s3/attachment/")) + } + + @Test(expected = FileDocumentNotFoundException::class) + fun `없는 ID를 조회하면 예외를 올린다`() { + service.findById(1) + } + + @Test + fun `파일명 조회는 카테고리 키로 찾는다`() { + repository.saved += FileDocument( + id = 1, + originalName = "지원서.pdf", + objectKey = "application/application_1001.pdf", + bucket = "entrydsm", + contentType = "application/pdf", + sizeBytes = 10, + checksum = "abc", + ) + + assertEquals(1L, service.findByFileName(FileCategory.APPLICATION, "application_1001.pdf")?.id) + assertNull(service.findByFileName(FileCategory.APPLICATION, "application_9999.pdf")) + } + + private fun command( + category: FileCategory = FileCategory.APPLICATION, + originalName: String = "지원서.pdf", + fileName: String = "application_1001.pdf", + sizeBytes: Long = 1024, + ) = UploadFileCommand(category, originalName, fileName, sizeBytes) + + private fun content(): InputStream = ByteArrayInputStream(ByteArray(4)) + + private class FakeStoragePort : StoragePort { + val existingKeys = mutableSetOf() + val uploaded = mutableListOf() + val deleted = mutableListOf() + var failOnDelete = false + + override fun upload( + objectKey: String, + contentType: String, + sizeBytes: Long, + content: InputStream, + ): StoredObject { + uploaded += objectKey + existingKeys += objectKey + return StoredObject(bucket = "entrydsm", objectKey = objectKey, checksum = "abc") + } + + override fun issueDownloadUrl(objectKey: String, expiresInSeconds: Long): String = + "https://s3/$objectKey?expires=$expiresInSeconds" + + override fun exists(objectKey: String): Boolean = objectKey in existingKeys + + override fun delete(objectKey: String) { + if (failOnDelete) throw IllegalStateException("delete failed") + deleted += objectKey + } + } + + private class FakeFileDocumentRepository : FileDocumentRepository { + val saved = mutableListOf() + var failOnSave = false + + override fun save(fileDocument: FileDocument): FileDocument { + if (failOnSave) throw IllegalStateException("save failed") + val stored = fileDocument.copy(id = saved.size + 1L) + saved += stored + return stored + } + + override fun findById(id: Long): FileDocument? = saved.firstOrNull { it.id == id } + + override fun findByObjectKey(objectKey: String): FileDocument? = + saved.firstOrNull { it.objectKey == objectKey } + + override fun existsById(id: Long): Boolean = saved.any { it.id == id } + + override fun deleteByObjectKey(objectKey: String) { + saved.removeIf { it.objectKey == objectKey } + } + } +} diff --git a/systems/configuration/configuration-bootstrap/ddl/files.sql b/systems/configuration/configuration-bootstrap/ddl/files.sql new file mode 100644 index 00000000..ac37267f --- /dev/null +++ b/systems/configuration/configuration-bootstrap/ddl/files.sql @@ -0,0 +1,18 @@ +-- configuration_db +-- ddl-auto: validate 이므로 애플리케이션 기동 전에 적용되어 있어야 한다. +-- 마이그레이션 도구(Flyway 등) 도입 전까지 수기로 적용한다. + +CREATE TABLE IF NOT EXISTS files +( + id BIGINT NOT NULL AUTO_INCREMENT, + original_name VARCHAR(255) NOT NULL, + object_key VARCHAR(255) NOT NULL, + bucket VARCHAR(100) NOT NULL, + content_type VARCHAR(100) NOT NULL, + size_bytes BIGINT NOT NULL, + checksum VARCHAR(64) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_files_object_key (object_key) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; diff --git a/systems/configuration/configuration-bootstrap/src/main/kotlin/hs/kr/entrydsm/DocumentBeanConfig.kt b/systems/configuration/configuration-bootstrap/src/main/kotlin/hs/kr/entrydsm/DocumentBeanConfig.kt new file mode 100644 index 00000000..7cfff32c --- /dev/null +++ b/systems/configuration/configuration-bootstrap/src/main/kotlin/hs/kr/entrydsm/DocumentBeanConfig.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.configuration + +import hs.kr.entrydsm.configuration.application.FileDocumentService +import hs.kr.entrydsm.configuration.domain.document.port.out.FileDocumentRepository +import hs.kr.entrydsm.configuration.domain.document.port.out.StoragePort +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class DocumentBeanConfig { + + @Bean + fun fileDocumentService( + storagePort: StoragePort, + fileDocumentRepository: FileDocumentRepository, + @Value("\${aws.s3.presign-expiry-seconds:600}") presignExpirySeconds: Long, + ) = FileDocumentService(storagePort, fileDocumentRepository, presignExpirySeconds) +} diff --git a/systems/configuration/configuration-bootstrap/src/main/resources/application.yaml b/systems/configuration/configuration-bootstrap/src/main/resources/application.yaml index f92a34d9..b1e14063 100644 --- a/systems/configuration/configuration-bootstrap/src/main/resources/application.yaml +++ b/systems/configuration/configuration-bootstrap/src/main/resources/application.yaml @@ -17,6 +17,16 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect + servlet: + multipart: + max-file-size: 21MB + max-request-size: 21MB + +aws: + s3: + bucket: ${AWS_S3_BUCKET} + region: ${AWS_S3_REGION:ap-northeast-2} + presign-expiry-seconds: ${AWS_S3_PRESIGN_EXPIRY_SECONDS:600} server: shutdown: graceful @@ -24,6 +34,12 @@ server: grpc: port: ${GRPC_PORT:9090} +aws: + s3: + bucket: ${S3_BUCKET:entrydsm-document-local} + region: ${AWS_REGION:ap-northeast-2} + presign-expiry-seconds: ${S3_PRESIGN_EXPIRY_SECONDS:600} + management: endpoints: web: diff --git a/systems/configuration/configuration-bootstrap/src/main/resources/schema.sql b/systems/configuration/configuration-bootstrap/src/main/resources/schema.sql new file mode 100644 index 00000000..7e88ad8a --- /dev/null +++ b/systems/configuration/configuration-bootstrap/src/main/resources/schema.sql @@ -0,0 +1,24 @@ +-- configuration_db 스키마. +-- ddl-auto 가 validate 이고 마이그레이션 도구가 없으므로 배포 전에 직접 적용한다. + +CREATE TABLE IF NOT EXISTS environment_variable ( + id BIGINT NOT NULL AUTO_INCREMENT, + env_key VARCHAR(255) NOT NULL, + env_value TEXT NOT NULL, + description TEXT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_environment_variable_env_key (env_key) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +CREATE TABLE IF NOT EXISTS files ( + id BIGINT NOT NULL AUTO_INCREMENT, + original_name VARCHAR(255) NOT NULL, + object_key VARCHAR(255) NOT NULL, + bucket VARCHAR(100) NOT NULL, + content_type VARCHAR(100) NOT NULL, + size_bytes BIGINT NOT NULL, + checksum VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_files_object_key (object_key) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt index 604d1dbf..ce70ea90 100644 --- a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt @@ -5,6 +5,6 @@ import hs.kr.entrydsm.configuration.domain.document.FileCategory data class UploadFileCommand( val category: FileCategory, val originalName: String, + val fileName: String, val sizeBytes: Long, - val fileName: String? = null, ) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUnavailableException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUnavailableException.kt new file mode 100644 index 00000000..bccaf3fa --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUnavailableException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class StorageUnavailableException(objectKey: String, cause: Throwable? = null) : + RuntimeException("Storage request failed: $objectKey", cause)