Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7b795d8
chore(configuration): AWS S3 SDK 및 validation 의존성 등록 #26
tlgms Jul 27, 2026
9a91260
feat(configuration): files 테이블 영속성 어댑터 구현 #26
tlgms Jul 27, 2026
3d732de
feat(configuration): S3 스토리지 어댑터 구현 #26
tlgms Jul 27, 2026
4b9254c
feat(configuration): 파일 업로드/다운로드 유스케이스 구현 #26
tlgms Jul 27, 2026
787549e
feat(configuration): 공통 응답 규약 및 예외 핸들러 추가 #26
tlgms Jul 27, 2026
751de7d
feat(configuration): 파일 API 응답 DTO 및 공용 헬퍼 추가 #26
tlgms Jul 27, 2026
167546a
feat(configuration): 원서 파일 저장/조회/다운로드 API 구현 #26
tlgms Jul 27, 2026
eeaeec0
feat(configuration): 수험표 저장/다운로드 API 구현 #26
tlgms Jul 27, 2026
3f5d47c
feat(configuration): 지원자 목록 엑셀 저장/다운로드 API 구현 #26
tlgms Jul 27, 2026
fa5e904
feat(configuration): 증명사진 첨부 API 구현 #26
tlgms Jul 27, 2026
3efad02
feat(configuration): 게시글 첨부파일 저장/다운로드 API 구현 #26
tlgms Jul 27, 2026
66d3f45
feat(configuration): 전형 요강 다운로드 API 구현 #26
tlgms Jul 27, 2026
901d1ae
chore(configuration): 파일 업로드 및 S3 부트스트랩 설정 추가 #26
tlgms Jul 27, 2026
0adce4d
Merge branch 'feat(document)-파일-도메인-모델-#26' into feat(document)-파일-저장…
tlgms Aug 19, 2026
2175ee9
refactor(configuration): objectKeyOf 로 이동한 파일명 검증 중복 호출 제거 #26
tlgms Aug 19, 2026
d7488f8
Merge branch 'feat(document)-파일-저장소-어댑터-#26' into feat(document)-파일-응…
tlgms Aug 19, 2026
a06cb19
fix(configuration): 참조 ID 접두사 검증과 요청 오류 예외 범위 정리 #26
tlgms Aug 19, 2026
d1aefba
test(configuration): 파일 API 공통 응답과 예외 변환 계약 테스트 추가 #26
tlgms Aug 19, 2026
37b8eab
Merge branch 'feat(document)-파일-응답-규약-#26' into feat(document)-파일-res…
tlgms Aug 19, 2026
7881508
fix(configuration): 지원서 조회 선택 규칙과 업로드 형식 검증 정리 #26
tlgms Aug 19, 2026
19167f9
test(configuration): 파일 REST API 11종 컨트롤러 테스트 추가 #26
tlgms Aug 19, 2026
17b3e83
fix(configuration): S3 존재 확인과 삭제의 예외를 도메인 예외로 변환 #26
tlgms Aug 19, 2026
1e29beb
refactor(configuration): 파일 서비스의 Spring 의존성과 트랜잭션 경계 정리 #26
tlgms Aug 19, 2026
eed5a9e
fix(configuration): 덮어쓴 객체 보호와 files 스키마 추가 #26
tlgms Aug 19, 2026
22b059e
test(configuration): 파일 저장 흐름과 S3 어댑터 테스트 추가 #26
tlgms Aug 19, 2026
87ab12c
fix(configuration): S3 버킷 설정 항목 추가 #26
tlgms Aug 19, 2026
7c33444
Merge branch 'feat(document)-파일-저장소-어댑터-#26' into feat(document)-파일-응…
tlgms Aug 19, 2026
a73f2a1
feat(configuration): 스토리지 접근 실패 오류 코드 추가 #26
tlgms Aug 19, 2026
96605cc
Merge branch 'feat(document)-파일-응답-규약-#26' into feat(document)-파일-res…
tlgms Aug 19, 2026
05cbfc7
fix(configuration): S3Presigner 테스트 대역을 동적 프록시로 교체 #26
tlgms Aug 19, 2026
239c822
Merge branch 'feat(document)-파일-저장소-어댑터-#26' into feat(document)-파일-응…
tlgms Aug 19, 2026
60b78fe
Merge branch 'feat(document)-파일-응답-규약-#26' into feat(document)-파일-res…
tlgms Aug 19, 2026
092a135
fix(configuration): 같은 객체 키 재업로드 시 기존 행을 갱신 #26
tlgms Aug 27, 2026
26d829d
Merge pull request #58 from EntryDSM/feat(document)-파일-응답-규약-#26
tlgms Aug 27, 2026
492d20f
Merge pull request #59 from EntryDSM/feat(document)-파일-rest-api-#26
tlgms Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions kotlin.MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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")
1 change: 1 addition & 0 deletions systems/configuration/configuration-adapter-out/deps.bzl
Original file line number Diff line number Diff line change
@@ -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",
]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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

@Component
class FileDocumentPersistenceAdapter(
private val fileDocumentJpaRepository: FileDocumentJpaRepository,
) : FileDocumentRepository {

override fun save(fileDocument: FileDocument): FileDocument =
fileDocumentJpaRepository.save(
FileDocumentJpaEntity.from(fileDocument)
).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)

override fun deleteByObjectKey(objectKey: String) =
fileDocumentJpaRepository.deleteByObjectKey(objectKey)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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.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.NoSuchKeyException
import software.amazon.awssdk.services.s3.model.PutObjectRequest
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: NoSuchKeyException) {
false
}

override fun delete(objectKey: String) {
s3Client.deleteObject(
DeleteObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.build()
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
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(),
)
}
}
Original file line number Diff line number Diff line change
@@ -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<FileDocumentJpaEntity, Long> {
fun findByObjectKey(objectKey: String): FileDocumentJpaEntity?
fun deleteByObjectKey(objectKey: String)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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 org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.io.InputStream

@Service
@Transactional(readOnly = true)
class FileDocumentService(
private val storagePort: StoragePort,
private val fileDocumentRepository: FileDocumentRepository,
@Value("\${aws.s3.presign-expiry-seconds:600}") private val presignExpirySeconds: Long,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
) : UploadFileUseCase,
IssueDownloadUrlUseCase,
ReadFileUseCase {

private val log = LoggerFactory.getLogger(javaClass)

@Transactional
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(FileNaming.requireSafeFileName(command.fileName))
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) {
deleteOrphan(objectKey)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
throw e
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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(FileNaming.requireSafeFileName(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) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)