From 80b665ac2d58d13af7d5cfd0a82a24a3a3cf5fbc Mon Sep 17 00:00:00 2001 From: tlgms Date: Mon, 27 Jul 2026 20:36:43 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(configuration):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F?= =?UTF-8?q?=20=ED=98=95=EC=8B=9D=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileExtension: 확장자↔MIME 매핑, jpeg 별칭 허용 - FileCategory: 용도별 object_key prefix, 허용 확장자, 최대 용량 - FileDocument: files 테이블 대응 도메인 모델 Co-Authored-By: Claude Opus 5 --- .../domain/document/FileCategory.kt | 25 +++++++++++++ .../domain/document/FileDocument.kt | 17 +++++++++ .../domain/document/FileExtension.kt | 35 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileDocument.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt new file mode 100644 index 00000000..652b9819 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt @@ -0,0 +1,25 @@ +package hs.kr.entrydsm.configuration.domain.document + +private const val MAX_DOCUMENT_SIZE_BYTES = 10L * 1024 * 1024 +private const val MAX_PHOTO_SIZE_BYTES = 5L * 1024 * 1024 +private const val MAX_ATTACHMENT_SIZE_BYTES = 20L * 1024 * 1024 + +enum class FileCategory( + val prefix: String, + val allowedExtensions: Set, + val maxSizeBytes: Long, +) { + APPLICATION("application", FileExtension.DOCUMENT_FORMATS, MAX_DOCUMENT_SIZE_BYTES), + ADMISSION_TICKET("admission-ticket", FileExtension.DOCUMENT_FORMATS, MAX_DOCUMENT_SIZE_BYTES), + APPLICANT_LIST("applicant-list", setOf(FileExtension.XLSX), MAX_DOCUMENT_SIZE_BYTES), + PHOTO("photo", FileExtension.IMAGE_FORMATS, MAX_PHOTO_SIZE_BYTES), + ATTACHMENT("attachment", FileExtension.ATTACHMENT_FORMATS, MAX_ATTACHMENT_SIZE_BYTES), + GUIDELINE("guideline", FileExtension.ATTACHMENT_FORMATS, MAX_ATTACHMENT_SIZE_BYTES), + ; + + fun supports(extension: FileExtension): Boolean = extension in allowedExtensions + + fun exceedsMaxSize(sizeBytes: Long): Boolean = sizeBytes > maxSizeBytes + + fun objectKeyOf(fileName: String): String = "$prefix/$fileName" +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileDocument.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileDocument.kt new file mode 100644 index 00000000..8a93cc01 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileDocument.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.configuration.domain.document + +import java.time.Instant + +data class FileDocument( + val id: Long? = null, + val originalName: String, + val objectKey: String, + val bucket: String, + val contentType: String, + val sizeBytes: Long, + val checksum: String, + val createdAt: Instant? = null, +) { + val fileName: String + get() = objectKey.substringAfterLast('/') +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt new file mode 100644 index 00000000..213bedd2 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt @@ -0,0 +1,35 @@ +package hs.kr.entrydsm.configuration.domain.document + +enum class FileExtension( + val value: String, + val contentType: String, + private val aliases: Set = emptySet(), +) { + PDF("pdf", "application/pdf"), + HWP("hwp", "application/x-hwp"), + XLSX("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + DOCX("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + JPG("jpg", "image/jpeg", setOf("jpeg")), + PNG("png", "image/png"), + WEBP("webp", "image/webp"), + ; + + companion object { + val DOCUMENT_FORMATS = setOf(PDF, HWP) + + val IMAGE_FORMATS = setOf(JPG, PNG, WEBP) + + val ATTACHMENT_FORMATS = DOCUMENT_FORMATS + IMAGE_FORMATS + setOf(XLSX, DOCX) + + fun fromFileName(fileName: String): FileExtension? { + val extension = fileName.substringAfterLast('.', "") + if (extension.isEmpty()) return null + return fromExtension(extension) + } + + fun fromExtension(extension: String): FileExtension? { + val normalized = extension.removePrefix(".").lowercase() + return entries.firstOrNull { it.value == normalized || normalized in it.aliases } + } + } +} From 49e3fc21ccedcd3e57b956dae89de582769f4ff5 Mon Sep 17 00:00:00 2001 From: tlgms Date: Mon, 27 Jul 2026 20:37:22 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(configuration):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=98=88=EC=99=B8=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명세의 에러 코드에 1:1 대응하는 예외를 추가한다. INVALID_FILE_FORMAT, FILE_TOO_LARGE, FILE_NOT_FOUND, STORAGE_UPLOAD_FAILED, PRESIGN_FAILED 및 키 조작 방어용 예외. Co-Authored-By: Claude Opus 5 --- .../document/exception/FileDocumentNotFoundException.kt | 4 ++++ .../domain/document/exception/FileTooLargeException.kt | 4 ++++ .../document/exception/InvalidFileFormatException.kt | 9 +++++++++ .../document/exception/InvalidFileNameException.kt | 4 ++++ .../domain/document/exception/PresignFailedException.kt | 4 ++++ .../document/exception/StorageUploadFailedException.kt | 4 ++++ 6 files changed, 29 insertions(+) create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileDocumentNotFoundException.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileTooLargeException.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileFormatException.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileNameException.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/PresignFailedException.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUploadFailedException.kt diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileDocumentNotFoundException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileDocumentNotFoundException.kt new file mode 100644 index 00000000..32340a4d --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileDocumentNotFoundException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class FileDocumentNotFoundException(objectKey: String) : + RuntimeException("File not found: $objectKey") diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileTooLargeException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileTooLargeException.kt new file mode 100644 index 00000000..135db72b --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/FileTooLargeException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class FileTooLargeException(sizeBytes: Long, maxSizeBytes: Long) : + RuntimeException("File size $sizeBytes bytes exceeds limit of $maxSizeBytes bytes") diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileFormatException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileFormatException.kt new file mode 100644 index 00000000..4a2b262f --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileFormatException.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +class InvalidFileFormatException(fileName: String, category: FileCategory) : + RuntimeException( + "Unsupported file format for ${category.name}: $fileName " + + "(allowed: ${category.allowedExtensions.joinToString { it.value }})" + ) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileNameException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileNameException.kt new file mode 100644 index 00000000..e1a58354 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/InvalidFileNameException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class InvalidFileNameException(fileName: String) : + RuntimeException("Invalid file name: $fileName") diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/PresignFailedException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/PresignFailedException.kt new file mode 100644 index 00000000..68ff8f41 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/PresignFailedException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class PresignFailedException(objectKey: String, cause: Throwable? = null) : + RuntimeException("Failed to issue download URL: $objectKey", cause) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUploadFailedException.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUploadFailedException.kt new file mode 100644 index 00000000..c49cb5b9 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/StorageUploadFailedException.kt @@ -0,0 +1,4 @@ +package hs.kr.entrydsm.configuration.domain.document.exception + +class StorageUploadFailedException(objectKey: String, cause: Throwable? = null) : + RuntimeException("Failed to upload file to storage: $objectKey", cause) From 194532a227e4e1768b4cc7494814c1141d49373d Mon Sep 17 00:00:00 2001 From: tlgms Date: Mon, 27 Jul 2026 20:38:12 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(configuration):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=8F=AC=ED=8A=B8=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=A0=95=EC=9D=98=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - port/in: UploadFileUseCase, IssueDownloadUrlUseCase, ReadFileUseCase - port/out: StoragePort(S3), FileDocumentRepository(files) - DownloadUrl, StoredObject 값 객체와 커맨드 추가 바이너리는 커맨드가 아니라 InputStream 파라미터로 분리해 커맨드의 값 의미를 유지한다. Co-Authored-By: Claude Opus 5 --- .../domain/document/DownloadUrl.kt | 7 +++++++ .../domain/document/StoredObject.kt | 7 +++++++ .../command/IssueDownloadUrlCommand.kt | 8 ++++++++ .../document/command/UploadFileCommand.kt | 10 ++++++++++ .../port/in/IssueDownloadUrlUseCase.kt | 9 +++++++++ .../document/port/in/ReadFileUseCase.kt | 10 ++++++++++ .../document/port/in/UploadFileUseCase.kt | 9 +++++++++ .../port/out/FileDocumentRepository.kt | 11 +++++++++++ .../domain/document/port/out/StoragePort.kt | 19 +++++++++++++++++++ 9 files changed, 90 insertions(+) create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/DownloadUrl.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/IssueDownloadUrlCommand.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/IssueDownloadUrlUseCase.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/ReadFileUseCase.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/UploadFileUseCase.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/DownloadUrl.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/DownloadUrl.kt new file mode 100644 index 00000000..011d7462 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/DownloadUrl.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.configuration.domain.document + +data class DownloadUrl( + val fileName: String, + val downloadUrl: String, + val expiresIn: Long, +) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt new file mode 100644 index 00000000..dc443a76 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.configuration.domain.document + +data class StoredObject( + val bucket: String, + val objectKey: String, + val checksum: String, +) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/IssueDownloadUrlCommand.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/IssueDownloadUrlCommand.kt new file mode 100644 index 00000000..e79e30dc --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/IssueDownloadUrlCommand.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.configuration.domain.document.command + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +data class IssueDownloadUrlCommand( + val category: FileCategory, + val fileName: String, +) 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 new file mode 100644 index 00000000..604d1dbf --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.configuration.domain.document.command + +import hs.kr.entrydsm.configuration.domain.document.FileCategory + +data class UploadFileCommand( + val category: FileCategory, + val originalName: String, + val sizeBytes: Long, + val fileName: String? = null, +) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/IssueDownloadUrlUseCase.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/IssueDownloadUrlUseCase.kt new file mode 100644 index 00000000..91b99443 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/IssueDownloadUrlUseCase.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.configuration.domain.document.port.`in` + +import hs.kr.entrydsm.configuration.domain.document.DownloadUrl +import hs.kr.entrydsm.configuration.domain.document.command.IssueDownloadUrlCommand + +interface IssueDownloadUrlUseCase { + fun issueByCommand(command: IssueDownloadUrlCommand): DownloadUrl + fun issueById(id: Long): DownloadUrl +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/ReadFileUseCase.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/ReadFileUseCase.kt new file mode 100644 index 00000000..aaa97840 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/ReadFileUseCase.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.configuration.domain.document.port.`in` + +import hs.kr.entrydsm.configuration.domain.document.FileCategory +import hs.kr.entrydsm.configuration.domain.document.FileDocument + +interface ReadFileUseCase { + fun findById(id: Long): FileDocument + fun findByFileName(category: FileCategory, fileName: String): FileDocument? + fun existsById(id: Long): Boolean +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/UploadFileUseCase.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/UploadFileUseCase.kt new file mode 100644 index 00000000..ed4bdc4a --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/in/UploadFileUseCase.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.configuration.domain.document.port.`in` + +import hs.kr.entrydsm.configuration.domain.document.FileDocument +import hs.kr.entrydsm.configuration.domain.document.command.UploadFileCommand +import java.io.InputStream + +interface UploadFileUseCase { + fun upload(command: UploadFileCommand, content: InputStream): FileDocument +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt new file mode 100644 index 00000000..c3c4b406 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.configuration.domain.document.port.out + +import hs.kr.entrydsm.configuration.domain.document.FileDocument + +interface FileDocumentRepository { + fun save(fileDocument: FileDocument): FileDocument + fun findById(id: Long): FileDocument? + fun findByObjectKey(objectKey: String): FileDocument? + fun existsById(id: Long): Boolean + fun deleteByObjectKey(objectKey: String) +} diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt new file mode 100644 index 00000000..ceed8aac --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.configuration.domain.document.port.out + +import hs.kr.entrydsm.configuration.domain.document.StoredObject +import java.io.InputStream + +interface StoragePort { + fun upload( + objectKey: String, + contentType: String, + sizeBytes: Long, + content: InputStream, + ): StoredObject + + fun issueDownloadUrl(objectKey: String, expiresInSeconds: Long): String + + fun exists(objectKey: String): Boolean + + fun delete(objectKey: String) +} From 6496df3e54902355f5d0443528f1b6b6831ff052 Mon Sep 17 00:00:00 2001 From: tlgms Date: Mon, 27 Jul 2026 20:39:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(configuration):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EC=83=9D=EC=84=B1=20=EA=B7=9C=EC=B9=99=20=EB=B0=8F?= =?UTF-8?q?=20=ED=82=A4=20=EC=A1=B0=EC=9E=91=20=EB=B0=A9=EC=96=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명세 응답 예시(application_1001.pdf, photo_5f3c9a2b.jpg 등)에 맞는 파일명 생성 규칙을 도메인에 둔다. 경로 구분자, 상위 경로 참조, 허용 외 문자를 걸러 object_key 조작을 막는다. 지원자 목록 excel 다운로드는 fileName이 그대로 키에 붙으므로 requireSafeFileName으로 거부한다. Co-Authored-By: Claude Opus 5 --- .../domain/document/FileNaming.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt new file mode 100644 index 00000000..c2a523d5 --- /dev/null +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.configuration.domain.document + +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.UUID + +private val SAFE_IDENTIFIER = Regex("[A-Za-z0-9_-]+") +private val UNSAFE_CHARACTERS = Regex("[^A-Za-z0-9._-]") +private val APPLICANT_LIST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd") +private const val TOKEN_LENGTH = 8 + +object FileNaming { + + fun applicationFileName(receiptCode: String, extension: FileExtension): String = + "application_${requireIdentifier(receiptCode)}.${extension.value}" + + fun admissionTicketFileName(receiptCode: String, extension: FileExtension): String = + "admission_ticket_${requireIdentifier(receiptCode)}.${extension.value}" + + fun applicantListFileName(date: LocalDate): String = + "applicants_${APPLICANT_LIST_DATE.format(date)}.${FileExtension.XLSX.value}" + + fun photoFileName(extension: FileExtension): String = + "photo_${randomToken()}.${extension.value}" + + fun attachmentFileName(originalName: String): String = + "${randomToken()}_${sanitizeOriginalName(originalName)}" + + fun requireIdentifier(value: String): String { + if (!SAFE_IDENTIFIER.matches(value)) throw InvalidFileNameException(value) + return value + } + + fun requireSafeFileName(fileName: String): String { + if (sanitizeOriginalName(fileName) != fileName) throw InvalidFileNameException(fileName) + return fileName + } + + fun sanitizeOriginalName(originalName: String): String { + val baseName = originalName + .substringAfterLast('/') + .substringAfterLast('\\') + .trim() + val sanitized = UNSAFE_CHARACTERS.replace(baseName, "_").trimStart('.') + if (sanitized.isEmpty() || sanitized == "_") throw InvalidFileNameException(originalName) + return sanitized + } + + private fun randomToken(): String = + UUID.randomUUID().toString().replace("-", "").take(TOKEN_LENGTH) +} From eeddabd290f0520e3b76d923e049ea0f352462ac Mon Sep 17 00:00:00 2001 From: tlgms Date: Mon, 27 Jul 2026 20:42:40 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test(configuration):=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B7=9C=EC=B9=99=20=ED=85=8C?= =?UTF-8?q?=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 확장자 인식, 카테고리별 허용 형식/용량, 명세 예시와 동일한 파일명 생성, 경로 조작 거부를 검증한다. 기존 test 타깃이 :main에 의존하지 않아 도메인 클래스를 참조하는 테스트가 컴파일되지 않던 문제를 함께 수정한다. Co-Authored-By: Claude Opus 5 --- .../configuration-domain/BUILD.bazel | 11 +- .../domain/document/DocumentDomainTest.kt | 100 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt diff --git a/systems/configuration/configuration-domain/BUILD.bazel b/systems/configuration/configuration-domain/BUILD.bazel index ebccba16..655086cf 100644 --- a/systems/configuration/configuration-domain/BUILD.bazel +++ b/systems/configuration/configuration-domain/BUILD.bazel @@ -17,5 +17,14 @@ kt_jvm_test( javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", test_class = "hs.kr.entrydsm.configuration.domain.ConfigurationDomainModuleTest", - deps = MODULE_DEPS + TEST_DEPS, + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "document_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.configuration.domain.document.DocumentDomainTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], ) diff --git a/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt b/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt new file mode 100644 index 00000000..1c7bdb85 --- /dev/null +++ b/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt @@ -0,0 +1,100 @@ +package hs.kr.entrydsm.configuration.domain.document + +import hs.kr.entrydsm.configuration.domain.document.exception.InvalidFileNameException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +class DocumentDomainTest { + + @Test + fun `확장자를 대소문자 구분 없이 인식한다`() { + assertEquals(FileExtension.PDF, FileExtension.fromFileName("application_1001.PDF")) + assertEquals(FileExtension.XLSX, FileExtension.fromExtension(".xlsx")) + } + + @Test + fun `jpeg는 jpg의 별칭으로 처리한다`() { + assertEquals(FileExtension.JPG, FileExtension.fromFileName("photo.jpeg")) + assertEquals("image/jpeg", FileExtension.JPG.contentType) + } + + @Test + fun `확장자가 없으면 인식하지 않는다`() { + assertNull(FileExtension.fromFileName("noextension")) + assertNull(FileExtension.fromFileName("unknown.exe")) + } + + @Test + fun `카테고리마다 허용 확장자가 다르다`() { + assertTrue(FileCategory.APPLICATION.supports(FileExtension.PDF)) + assertFalse(FileCategory.APPLICATION.supports(FileExtension.JPG)) + assertEquals(setOf(FileExtension.XLSX), FileCategory.APPLICANT_LIST.allowedExtensions) + assertTrue(FileCategory.PHOTO.supports(FileExtension.WEBP)) + assertFalse(FileCategory.PHOTO.supports(FileExtension.PDF)) + assertTrue(FileCategory.ATTACHMENT.supports(FileExtension.DOCX)) + } + + @Test + fun `카테고리별 용량 한도를 넘으면 초과로 판정한다`() { + assertFalse(FileCategory.PHOTO.exceedsMaxSize(FileCategory.PHOTO.maxSizeBytes)) + assertTrue(FileCategory.PHOTO.exceedsMaxSize(FileCategory.PHOTO.maxSizeBytes + 1)) + } + + @Test + fun `object key는 카테고리 prefix를 붙인다`() { + assertEquals( + "admission-ticket/admission_ticket_1001.pdf", + FileCategory.ADMISSION_TICKET.objectKeyOf("admission_ticket_1001.pdf"), + ) + } + + @Test + fun `명세 예시와 같은 파일명을 만든다`() { + assertEquals( + "application_1001.pdf", + FileNaming.applicationFileName("1001", FileExtension.PDF), + ) + assertEquals( + "admission_ticket_1001.pdf", + FileNaming.admissionTicketFileName("1001", FileExtension.PDF), + ) + assertEquals( + "applicants_20260726.xlsx", + FileNaming.applicantListFileName(LocalDate.of(2026, 7, 26)), + ) + } + + @Test + fun `증명사진과 첨부파일 이름에 랜덤 토큰을 붙인다`() { + assertTrue(FileNaming.photoFileName(FileExtension.JPG).matches(Regex("photo_[0-9a-f]{8}\\.jpg"))) + assertTrue(FileNaming.attachmentFileName("guide.pdf").matches(Regex("[0-9a-f]{8}_guide\\.pdf"))) + } + + @Test + fun `원본 파일명에서 경로를 제거하고 허용 외 문자를 치환한다`() { + assertEquals("pas_swd.pdf", FileNaming.sanitizeOriginalName("../../etc/pas swd.pdf")) + assertEquals("report.xlsx", FileNaming.sanitizeOriginalName("C:\\temp\\report.xlsx")) + } + + @Test(expected = InvalidFileNameException::class) + fun `수험번호에 경로 문자가 들어오면 거부한다`() { + FileNaming.requireIdentifier("../1001") + } + + @Test(expected = InvalidFileNameException::class) + fun `다운로드 파일명에 상위 경로 참조가 들어오면 거부한다`() { + FileNaming.requireSafeFileName("../../etc/passwd") + } + + @Test + fun `정상 다운로드 파일명은 그대로 통과시킨다`() { + assertEquals( + "applicants_20260726.xlsx", + FileNaming.requireSafeFileName("applicants_20260726.xlsx"), + ) + } +} From 5aca244a8e2ab3486016ca1b8840aa331a5698b7 Mon Sep 17 00:00:00 2001 From: tlgms Date: Tue, 11 Aug 2026 21:29:29 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(configuration):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=ED=82=A4=20=EB=9E=9C=EB=8D=A4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=84=2032=EB=B9=84=ED=8A=B8=EC=97=90=EC=84=9C=201?= =?UTF-8?q?28=EB=B9=84=ED=8A=B8=EB=A1=9C=20=ED=99=95=EC=9E=A5=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hs/kr/entrydsm/configuration/domain/document/FileNaming.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt index c2a523d5..86a17963 100644 --- a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt @@ -8,7 +8,6 @@ import java.util.UUID private val SAFE_IDENTIFIER = Regex("[A-Za-z0-9_-]+") private val UNSAFE_CHARACTERS = Regex("[^A-Za-z0-9._-]") private val APPLICANT_LIST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd") -private const val TOKEN_LENGTH = 8 object FileNaming { @@ -48,5 +47,5 @@ object FileNaming { } private fun randomToken(): String = - UUID.randomUUID().toString().replace("-", "").take(TOKEN_LENGTH) + UUID.randomUUID().toString().replace("-", "") } From 42ebb0c5464ed45e7a9d0ec95234e5cfbc88f63b Mon Sep 17 00:00:00 2001 From: tlgms Date: Wed, 19 Aug 2026 22:53:02 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(configuration):=20object=20key=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=EC=84=B1=20=EA=B2=80=EC=A6=9D=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 FileCategory.objectKeyOf 가 파일명을 검증 없이 prefix 에 이어붙여 경로 구분자나 상위 경로 참조를 그대로 키에 넣을 수 있었다. FileNaming.requireSafeFileName 을 통과한 값만 사용하도록 바꾸고, 128비트로 확장된 랜덤 토큰 길이에 맞춰 깨져 있던 테스트 기대값도 수정했다. Co-Authored-By: Claude Opus 5 --- .../configuration/domain/document/FileCategory.kt | 12 ++++++------ .../domain/document/DocumentDomainTest.kt | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt index 652b9819..d76abe92 100644 --- a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt @@ -9,17 +9,17 @@ enum class FileCategory( val allowedExtensions: Set, val maxSizeBytes: Long, ) { - APPLICATION("application", FileExtension.DOCUMENT_FORMATS, MAX_DOCUMENT_SIZE_BYTES), - ADMISSION_TICKET("admission-ticket", FileExtension.DOCUMENT_FORMATS, MAX_DOCUMENT_SIZE_BYTES), + APPLICATION("application", FileExtension.documentFormats, MAX_DOCUMENT_SIZE_BYTES), + ADMISSION_TICKET("admission-ticket", FileExtension.documentFormats, MAX_DOCUMENT_SIZE_BYTES), APPLICANT_LIST("applicant-list", setOf(FileExtension.XLSX), MAX_DOCUMENT_SIZE_BYTES), - PHOTO("photo", FileExtension.IMAGE_FORMATS, MAX_PHOTO_SIZE_BYTES), - ATTACHMENT("attachment", FileExtension.ATTACHMENT_FORMATS, MAX_ATTACHMENT_SIZE_BYTES), - GUIDELINE("guideline", FileExtension.ATTACHMENT_FORMATS, MAX_ATTACHMENT_SIZE_BYTES), + PHOTO("photo", FileExtension.imageFormats, MAX_PHOTO_SIZE_BYTES), + ATTACHMENT("attachment", FileExtension.attachmentFormats, MAX_ATTACHMENT_SIZE_BYTES), + GUIDELINE("guideline", FileExtension.attachmentFormats, MAX_ATTACHMENT_SIZE_BYTES), ; fun supports(extension: FileExtension): Boolean = extension in allowedExtensions fun exceedsMaxSize(sizeBytes: Long): Boolean = sizeBytes > maxSizeBytes - fun objectKeyOf(fileName: String): String = "$prefix/$fileName" + fun objectKeyOf(fileName: String): String = "$prefix/${FileNaming.requireSafeFileName(fileName)}" } diff --git a/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt b/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt index 1c7bdb85..f7068128 100644 --- a/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt +++ b/systems/configuration/configuration-domain/src/test/kotlin/hs/kr/entrydsm/configuration/domain/document/DocumentDomainTest.kt @@ -52,6 +52,11 @@ class DocumentDomainTest { ) } + @Test(expected = InvalidFileNameException::class) + fun `object key에 상위 경로 참조가 들어오면 거부한다`() { + FileCategory.APPLICANT_LIST.objectKeyOf("../../etc/passwd") + } + @Test fun `명세 예시와 같은 파일명을 만든다`() { assertEquals( @@ -70,8 +75,8 @@ class DocumentDomainTest { @Test fun `증명사진과 첨부파일 이름에 랜덤 토큰을 붙인다`() { - assertTrue(FileNaming.photoFileName(FileExtension.JPG).matches(Regex("photo_[0-9a-f]{8}\\.jpg"))) - assertTrue(FileNaming.attachmentFileName("guide.pdf").matches(Regex("[0-9a-f]{8}_guide\\.pdf"))) + assertTrue(FileNaming.photoFileName(FileExtension.JPG).matches(Regex("photo_[0-9a-f]{32}\\.jpg"))) + assertTrue(FileNaming.attachmentFileName("guide.pdf").matches(Regex("[0-9a-f]{32}_guide\\.pdf"))) } @Test From d61510a4eebb6370ba0cfff9413193432054e451 Mon Sep 17 00:00:00 2001 From: tlgms Date: Wed, 19 Aug 2026 22:53:02 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor(configuration):=20=EB=B9=84?= =?UTF-8?q?=EC=83=81=EC=88=98=20val=20=EC=9D=B4=EB=A6=84=EC=9D=84=20lowerC?= =?UTF-8?q?amelCase=20=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit const 가 아닌 프로퍼티에 UPPER_SNAKE_CASE 를 쓰고 있어 코딩 컨벤션과 어긋났다. Co-Authored-By: Claude Opus 5 --- .../configuration/domain/document/FileExtension.kt | 6 +++--- .../configuration/domain/document/FileNaming.kt | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt index 213bedd2..2482f9b1 100644 --- a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileExtension.kt @@ -15,11 +15,11 @@ enum class FileExtension( ; companion object { - val DOCUMENT_FORMATS = setOf(PDF, HWP) + val documentFormats = setOf(PDF, HWP) - val IMAGE_FORMATS = setOf(JPG, PNG, WEBP) + val imageFormats = setOf(JPG, PNG, WEBP) - val ATTACHMENT_FORMATS = DOCUMENT_FORMATS + IMAGE_FORMATS + setOf(XLSX, DOCX) + val attachmentFormats = documentFormats + imageFormats + setOf(XLSX, DOCX) fun fromFileName(fileName: String): FileExtension? { val extension = fileName.substringAfterLast('.', "") diff --git a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt index 86a17963..6657c6b0 100644 --- a/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt +++ b/systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt @@ -5,9 +5,9 @@ import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.UUID -private val SAFE_IDENTIFIER = Regex("[A-Za-z0-9_-]+") -private val UNSAFE_CHARACTERS = Regex("[^A-Za-z0-9._-]") -private val APPLICANT_LIST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd") +private val safeIdentifier = Regex("[A-Za-z0-9_-]+") +private val unsafeCharacters = Regex("[^A-Za-z0-9._-]") +private val applicantListDate = DateTimeFormatter.ofPattern("yyyyMMdd") object FileNaming { @@ -18,7 +18,7 @@ object FileNaming { "admission_ticket_${requireIdentifier(receiptCode)}.${extension.value}" fun applicantListFileName(date: LocalDate): String = - "applicants_${APPLICANT_LIST_DATE.format(date)}.${FileExtension.XLSX.value}" + "applicants_${applicantListDate.format(date)}.${FileExtension.XLSX.value}" fun photoFileName(extension: FileExtension): String = "photo_${randomToken()}.${extension.value}" @@ -27,7 +27,7 @@ object FileNaming { "${randomToken()}_${sanitizeOriginalName(originalName)}" fun requireIdentifier(value: String): String { - if (!SAFE_IDENTIFIER.matches(value)) throw InvalidFileNameException(value) + if (!safeIdentifier.matches(value)) throw InvalidFileNameException(value) return value } @@ -41,7 +41,7 @@ object FileNaming { .substringAfterLast('/') .substringAfterLast('\\') .trim() - val sanitized = UNSAFE_CHARACTERS.replace(baseName, "_").trimStart('.') + val sanitized = unsafeCharacters.replace(baseName, "_").trimStart('.') if (sanitized.isEmpty() || sanitized == "_") throw InvalidFileNameException(originalName) return sanitized }