Skip to content
Merged
11 changes: 10 additions & 1 deletion systems/configuration/configuration-domain/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package hs.kr.entrydsm.configuration.domain.document

data class DownloadUrl(
val fileName: String,
val downloadUrl: String,
val expiresIn: Long,
)
Original file line number Diff line number Diff line change
@@ -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<FileExtension>,
val maxSizeBytes: Long,
) {
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.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/${FileNaming.requireSafeFileName(fileName)}"
}
Original file line number Diff line number Diff line change
@@ -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('/')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package hs.kr.entrydsm.configuration.domain.document

enum class FileExtension(
val value: String,
val contentType: String,
private val aliases: Set<String> = 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 documentFormats = setOf(PDF, HWP)

val imageFormats = setOf(JPG, PNG, WEBP)

val attachmentFormats = documentFormats + imageFormats + 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 }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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 safeIdentifier = Regex("[A-Za-z0-9_-]+")
private val unsafeCharacters = Regex("[^A-Za-z0-9._-]")
private val applicantListDate = DateTimeFormatter.ofPattern("yyyyMMdd")

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_${applicantListDate.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 (!safeIdentifier.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 = unsafeCharacters.replace(baseName, "_").trimStart('.')
if (sanitized.isEmpty() || sanitized == "_") throw InvalidFileNameException(originalName)
return sanitized
}

private fun randomToken(): String =
UUID.randomUUID().toString().replace("-", "")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package hs.kr.entrydsm.configuration.domain.document

data class StoredObject(
val bucket: String,
val objectKey: String,
val checksum: String,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package hs.kr.entrydsm.configuration.domain.document.exception

class FileDocumentNotFoundException(objectKey: String) :
RuntimeException("File not found: $objectKey")
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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 }})"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package hs.kr.entrydsm.configuration.domain.document.exception

class InvalidFileNameException(fileName: String) :
RuntimeException("Invalid file name: $fileName")
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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(expected = InvalidFileNameException::class)
fun `object key에 상위 경로 참조가 들어오면 거부한다`() {
FileCategory.APPLICANT_LIST.objectKeyOf("../../etc/passwd")
}

@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]{32}\\.jpg")))
assertTrue(FileNaming.attachmentFileName("guide.pdf").matches(Regex("[0-9a-f]{32}_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"),
)
}
}
Loading