From 787549e8c0e5a2da75199fbd45c8e7b9d9f9772f Mon Sep 17 00:00:00 2001 From: tlgms Date: Mon, 27 Jul 2026 21:13:07 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(configuration):=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EA=B7=9C=EC=95=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명세의 성공/실패 응답 형식에 맞춘 ApiResponse envelope과 파일 관련 ErrorCode를 정의한다. 성공 응답에도 error 키를 항상 포함하고 timestamp는 실패 시에만 직렬화한다. 도메인 예외와 MaxUploadSizeExceededException을 명세의 HTTP 상태로 변환한다. 후자를 잡지 않으면 413이 아니라 500이 나간다. Co-Authored-By: Claude Opus 5 --- .../configuration-adapter-in/deps.bzl | 3 + .../adapterin/common/ApiResponse.kt | 32 ++++++++++ .../common/DocumentExceptionHandler.kt | 63 +++++++++++++++++++ .../adapterin/common/ErrorCode.kt | 13 ++++ 4 files changed, 111 insertions(+) create mode 100644 systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ApiResponse.kt create mode 100644 systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentExceptionHandler.kt create mode 100644 systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ErrorCode.kt diff --git a/systems/configuration/configuration-adapter-in/deps.bzl b/systems/configuration/configuration-adapter-in/deps.bzl index aff538e0..05ba53f3 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", 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..ce358bb1 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentExceptionHandler.kt @@ -0,0 +1,63 @@ +package hs.kr.entrydsm.configuration.adapterin.common + +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.StorageUploadFailedException +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +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, + MissingServletRequestParameterException::class, + MethodArgumentNotValidException::class, + IllegalArgumentException::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(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..70fc5a3b --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/common/ErrorCode.kt @@ -0,0 +1,13 @@ +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 발급에 실패했습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), +} From 751de7ddee8e3d7e33115f38dde711738ccab7db Mon Sep 17 00:00:00 2001 From: tlgms Date: Mon, 27 Jul 2026 21:14:16 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(configuration):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20API=20=EC=9D=91=EB=8B=B5=20DTO=20=EB=B0=8F=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명세의 응답 스키마에 대응하는 DTO와, 참조 ID(attachment_1 형태) 변환, MultipartFile→커맨드 변환 헬퍼를 추가한다. Co-Authored-By: Claude Opus 5 --- .../adapterin/document/FileReferenceId.kt | 14 ++++++ .../document/MultipartFileExtensions.kt | 20 ++++++++ .../adapterin/document/dto/FileResponses.kt | 49 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/FileReferenceId.kt create mode 100644 systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/MultipartFileExtensions.kt create mode 100644 systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/dto/FileResponses.kt 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..8d379300 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/main/kotlin/hs/kr/entrydsm/configuration/adapterin/document/FileReferenceId.kt @@ -0,0 +1,14 @@ +package hs.kr.entrydsm.configuration.adapterin.document + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +object FileReferenceId { + + fun of(category: FileCategory, id: Long): String = "${prefixOf(category)}$id" + + fun parse(category: FileCategory, value: String): Long = + value.removePrefix(prefixOf(category)).toLongOrNull() + ?: throw IllegalArgumentException("Invalid ${category.name.lowercase()} id: $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/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..af92d7c6 --- /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()) + ?: 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/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, +) From a06cb197b35b3e5851071e5a6a8fa6d740e21dda Mon Sep 17 00:00:00 2001 From: tlgms Date: Wed, 19 Aug 2026 22:57:19 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(configuration):=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?ID=20=EC=A0=91=EB=91=90=EC=82=AC=20=EA=B2=80=EC=A6=9D=EA=B3=BC?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=98=A4=EB=A5=98=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EB=B2=94=EC=9C=84=20=EC=A0=95=EB=A6=AC=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileReferenceId.parse 가 removePrefix 만 사용해 접두사 없는 값도 통과시켰다. 접두사가 있는 값만 허용하고, 실패는 전용 InvalidFileReferenceIdException 으로 알린다. 예외 핸들러는 IllegalArgumentException 을 통째로 400 으로 묶던 것을 멈추고 요청 바인딩 예외만 INVALID_REQUEST_PARAM 으로 매핑한다. Co-Authored-By: Claude Opus 5 --- .../adapterin/common/DocumentExceptionHandler.kt | 9 +++++++-- .../adapterin/document/FileReferenceId.kt | 13 ++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) 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 index ce358bb1..d8a7bf2f 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -8,10 +9,12 @@ import hs.kr.entrydsm.configuration.domain.document.exception.PresignFailedExcep import hs.kr.entrydsm.configuration.domain.document.exception.StorageUploadFailedException import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity -import org.springframework.web.bind.MissingServletRequestParameterException +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 @@ -33,9 +36,11 @@ class DocumentExceptionHandler { @ExceptionHandler( InvalidFileNameException::class, + InvalidFileReferenceIdException::class, MissingServletRequestParameterException::class, MethodArgumentNotValidException::class, - IllegalArgumentException::class, + MethodArgumentTypeMismatchException::class, + HttpMessageNotReadableException::class, ) fun handleInvalidRequestParam(e: Exception) = respond(ErrorCode.INVALID_REQUEST_PARAM, e) 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 index 8d379300..56f400b9 100644 --- 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 @@ -2,13 +2,20 @@ 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 = - value.removePrefix(prefixOf(category)).toLongOrNull() - ?: throw IllegalArgumentException("Invalid ${category.name.lowercase()} id: $value") + 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()}_" } From d1aefbae4076190dd07ce4d009d5338a3c2b5a6f Mon Sep 17 00:00:00 2001 From: tlgms Date: Wed, 19 Aug 2026 22:57:19 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test(configuration):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20API=20=EA=B3=B5=ED=86=B5=20=EC=9D=91=EB=8B=B5=EA=B3=BC=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=B3=80=ED=99=98=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiResponse, ErrorCode, DocumentExceptionHandler 의 상태 코드와 응답 본문, FileReferenceId 파싱, multipart 확장자 검증, 응답 DTO 변환을 고정한다. Co-Authored-By: Claude Opus 5 --- .../configuration-adapter-in/BUILD.bazel | 11 +- .../configuration-adapter-in/deps.bzl | 1 + .../common/DocumentApiContractTest.kt | 199 ++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentApiContractTest.kt diff --git a/systems/configuration/configuration-adapter-in/BUILD.bazel b/systems/configuration/configuration-adapter-in/BUILD.bazel index 40db9dd7..3a4f0e49 100644 --- a/systems/configuration/configuration-adapter-in/BUILD.bazel +++ b/systems/configuration/configuration-adapter-in/BUILD.bazel @@ -17,5 +17,14 @@ 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"], ) diff --git a/systems/configuration/configuration-adapter-in/deps.bzl b/systems/configuration/configuration-adapter-in/deps.bzl index 05ba53f3..ecb4c70c 100644 --- a/systems/configuration/configuration-adapter-in/deps.bzl +++ b/systems/configuration/configuration-adapter-in/deps.bzl @@ -17,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/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..b37aae18 --- /dev/null +++ b/systems/configuration/configuration-adapter-in/src/test/kotlin/hs/kr/entrydsm/configuration/adapterin/common/DocumentApiContractTest.kt @@ -0,0 +1,199 @@ +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.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.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")), + ) + } + + @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) + } +} From a73f2a1fabc5e0eea92259cf3388eaf2b6a07f6f Mon Sep 17 00:00:00 2001 From: tlgms Date: Thu, 20 Aug 2026 04:31:00 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(configuration):=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=A0=91=EA=B7=BC=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 존재 확인·삭제가 올리는 StorageUnavailableException 이 매핑되지 않아 502 대신 500 으로 나갔다. STORAGE_UNAVAILABLE 로 묶어 응답 규약에 넣는다. Co-Authored-By: Claude Opus 5 --- .../adapterin/common/DocumentExceptionHandler.kt | 5 +++++ .../kr/entrydsm/configuration/adapterin/common/ErrorCode.kt | 1 + .../adapterin/common/DocumentApiContractTest.kt | 6 ++++++ 3 files changed, 12 insertions(+) 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 index d8a7bf2f..752ded9d 100644 --- 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 @@ -6,6 +6,7 @@ import hs.kr.entrydsm.configuration.domain.document.exception.FileTooLargeExcept 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 @@ -53,6 +54,10 @@ class DocumentExceptionHandler { 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) 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 index 70fc5a3b..cf5b72fc 100644 --- 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 @@ -9,5 +9,6 @@ enum class ErrorCode(val status: HttpStatus, val message: String) { 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/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 index b37aae18..f3e26c03 100644 --- 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 @@ -15,6 +15,7 @@ import hs.kr.entrydsm.configuration.domain.document.exception.FileTooLargeExcept 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 @@ -67,6 +68,7 @@ class DocumentApiContractTest { 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) } @@ -96,6 +98,10 @@ class DocumentApiContractTest { ErrorCode.PRESIGN_FAILED, handler.handlePresignFailed(PresignFailedException("photo/a.jpg")), ) + assertMapped( + ErrorCode.STORAGE_UNAVAILABLE, + handler.handleStorageUnavailable(StorageUnavailableException("photo/a.jpg")), + ) } @Test