-
Notifications
You must be signed in to change notification settings - Fork 0
feat(configuration): S3 및 files 테이블 어댑터와 유스케이스 구현 #26 #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
The head ref may contain hidden characters: "feat(document)-\uD30C\uC77C-\uC800\uC7A5\uC18C-\uC5B4\uB311\uD130-#26"
Merged
Changes from 12 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 9a91260
feat(configuration): files 테이블 영속성 어댑터 구현 #26
tlgms 3d732de
feat(configuration): S3 스토리지 어댑터 구현 #26
tlgms 4b9254c
feat(configuration): 파일 업로드/다운로드 유스케이스 구현 #26
tlgms 787549e
feat(configuration): 공통 응답 규약 및 예외 핸들러 추가 #26
tlgms 751de7d
feat(configuration): 파일 API 응답 DTO 및 공용 헬퍼 추가 #26
tlgms 167546a
feat(configuration): 원서 파일 저장/조회/다운로드 API 구현 #26
tlgms eeaeec0
feat(configuration): 수험표 저장/다운로드 API 구현 #26
tlgms 3f5d47c
feat(configuration): 지원자 목록 엑셀 저장/다운로드 API 구현 #26
tlgms fa5e904
feat(configuration): 증명사진 첨부 API 구현 #26
tlgms 3efad02
feat(configuration): 게시글 첨부파일 저장/다운로드 API 구현 #26
tlgms 66d3f45
feat(configuration): 전형 요강 다운로드 API 구현 #26
tlgms 901d1ae
chore(configuration): 파일 업로드 및 S3 부트스트랩 설정 추가 #26
tlgms 0adce4d
Merge branch 'feat(document)-파일-도메인-모델-#26' into feat(document)-파일-저장…
tlgms 2175ee9
refactor(configuration): objectKeyOf 로 이동한 파일명 검증 중복 호출 제거 #26
tlgms d7488f8
Merge branch 'feat(document)-파일-저장소-어댑터-#26' into feat(document)-파일-응…
tlgms a06cb19
fix(configuration): 참조 ID 접두사 검증과 요청 오류 예외 범위 정리 #26
tlgms d1aefba
test(configuration): 파일 API 공통 응답과 예외 변환 계약 테스트 추가 #26
tlgms 37b8eab
Merge branch 'feat(document)-파일-응답-규약-#26' into feat(document)-파일-res…
tlgms 7881508
fix(configuration): 지원서 조회 선택 규칙과 업로드 형식 검증 정리 #26
tlgms 19167f9
test(configuration): 파일 REST API 11종 컨트롤러 테스트 추가 #26
tlgms 17b3e83
fix(configuration): S3 존재 확인과 삭제의 예외를 도메인 예외로 변환 #26
tlgms 1e29beb
refactor(configuration): 파일 서비스의 Spring 의존성과 트랜잭션 경계 정리 #26
tlgms eed5a9e
fix(configuration): 덮어쓴 객체 보호와 files 스키마 추가 #26
tlgms 22b059e
test(configuration): 파일 저장 흐름과 S3 어댑터 테스트 추가 #26
tlgms 87ab12c
fix(configuration): S3 버킷 설정 항목 추가 #26
tlgms 7c33444
Merge branch 'feat(document)-파일-저장소-어댑터-#26' into feat(document)-파일-응…
tlgms a73f2a1
feat(configuration): 스토리지 접근 실패 오류 코드 추가 #26
tlgms 96605cc
Merge branch 'feat(document)-파일-응답-규약-#26' into feat(document)-파일-res…
tlgms 05cbfc7
fix(configuration): S3Presigner 테스트 대역을 동적 프록시로 교체 #26
tlgms 239c822
Merge branch 'feat(document)-파일-저장소-어댑터-#26' into feat(document)-파일-응…
tlgms 60b78fe
Merge branch 'feat(document)-파일-응답-규약-#26' into feat(document)-파일-res…
tlgms 092a135
fix(configuration): 같은 객체 키 재업로드 시 기존 행을 갱신 #26
tlgms 26d829d
Merge pull request #58 from EntryDSM/feat(document)-파일-응답-규약-#26
tlgms 492d20f
Merge pull request #59 from EntryDSM/feat(document)-파일-rest-api-#26
tlgms File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
...src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package hs.kr.entrydsm.configuration.adapterout | ||
|
|
||
| import hs.kr.entrydsm.configuration.adapterout.entity.FileDocumentJpaEntity | ||
| import hs.kr.entrydsm.configuration.adapterout.repository.FileDocumentJpaRepository | ||
| import hs.kr.entrydsm.configuration.domain.document.FileDocument | ||
| import hs.kr.entrydsm.configuration.domain.document.port.out.FileDocumentRepository | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Component | ||
| @Transactional(readOnly = true) | ||
| class FileDocumentPersistenceAdapter( | ||
| private val fileDocumentJpaRepository: FileDocumentJpaRepository, | ||
| ) : FileDocumentRepository { | ||
|
|
||
| @Transactional | ||
| override fun save(fileDocument: FileDocument): FileDocument = | ||
| 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) | ||
|
|
||
| @Transactional | ||
| override fun deleteByObjectKey(objectKey: String) = | ||
| fileDocumentJpaRepository.deleteByObjectKey(objectKey) | ||
| } |
107 changes: 107 additions & 0 deletions
107
...n-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package hs.kr.entrydsm.configuration.adapterout | ||
|
|
||
| import hs.kr.entrydsm.configuration.domain.document.StoredObject | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.PresignFailedException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.StorageUnavailableException | ||
| import hs.kr.entrydsm.configuration.domain.document.exception.StorageUploadFailedException | ||
| import hs.kr.entrydsm.configuration.domain.document.port.out.StoragePort | ||
| import org.springframework.beans.factory.annotation.Value | ||
| import org.springframework.stereotype.Component | ||
| import software.amazon.awssdk.core.exception.SdkException | ||
| import software.amazon.awssdk.core.sync.RequestBody | ||
| import software.amazon.awssdk.services.s3.S3Client | ||
| import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm | ||
| import software.amazon.awssdk.services.s3.model.DeleteObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.GetObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.HeadObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.PutObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.S3Exception | ||
| import software.amazon.awssdk.services.s3.presigner.S3Presigner | ||
| import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest | ||
| import java.io.InputStream | ||
| import java.time.Duration | ||
|
|
||
| @Component | ||
| class S3StorageAdapter( | ||
| private val s3Client: S3Client, | ||
| private val s3Presigner: S3Presigner, | ||
| @Value("\${aws.s3.bucket}") private val bucket: String, | ||
| ) : StoragePort { | ||
|
|
||
| override fun upload( | ||
| objectKey: String, | ||
| contentType: String, | ||
| sizeBytes: Long, | ||
| content: InputStream, | ||
| ): StoredObject { | ||
| val response = try { | ||
| s3Client.putObject( | ||
| PutObjectRequest.builder() | ||
| .bucket(bucket) | ||
| .key(objectKey) | ||
| .contentType(contentType) | ||
| .contentLength(sizeBytes) | ||
| .checksumAlgorithm(ChecksumAlgorithm.SHA256) | ||
| .build(), | ||
| RequestBody.fromInputStream(content, sizeBytes), | ||
| ) | ||
| } catch (e: SdkException) { | ||
| throw StorageUploadFailedException(objectKey, e) | ||
| } | ||
| return StoredObject( | ||
| bucket = bucket, | ||
| objectKey = objectKey, | ||
| checksum = response.checksumSHA256() ?: response.eTag().orEmpty().trim('"'), | ||
| ) | ||
| } | ||
|
|
||
| override fun issueDownloadUrl(objectKey: String, expiresInSeconds: Long): String = | ||
| try { | ||
| s3Presigner.presignGetObject( | ||
| GetObjectPresignRequest.builder() | ||
| .signatureDuration(Duration.ofSeconds(expiresInSeconds)) | ||
| .getObjectRequest( | ||
| GetObjectRequest.builder() | ||
| .bucket(bucket) | ||
| .key(objectKey) | ||
| .build() | ||
| ) | ||
| .build() | ||
| ).url().toString() | ||
| } catch (e: SdkException) { | ||
| throw PresignFailedException(objectKey, e) | ||
| } | ||
|
|
||
| override fun exists(objectKey: String): Boolean = | ||
| try { | ||
| s3Client.headObject( | ||
| HeadObjectRequest.builder() | ||
| .bucket(bucket) | ||
| .key(objectKey) | ||
| .build() | ||
| ) | ||
| true | ||
| } catch (e: S3Exception) { | ||
| // HEAD 응답에는 본문이 없어 객체 없음이 NoSuchKeyException 대신 404 S3Exception 으로 올라오기도 한다. | ||
| if (e.statusCode() == HTTP_NOT_FOUND) false else throw StorageUnavailableException(objectKey, e) | ||
| } catch (e: SdkException) { | ||
| throw StorageUnavailableException(objectKey, e) | ||
| } | ||
|
|
||
| override fun delete(objectKey: String) { | ||
| try { | ||
| s3Client.deleteObject( | ||
| DeleteObjectRequest.builder() | ||
| .bucket(bucket) | ||
| .key(objectKey) | ||
| .build() | ||
| ) | ||
| } catch (e: SdkException) { | ||
| throw StorageUnavailableException(objectKey, e) | ||
| } | ||
| } | ||
|
|
||
| private companion object { | ||
| const val HTTP_NOT_FOUND = 404 | ||
| } | ||
| } | ||
26 changes: 26 additions & 0 deletions
26
...on-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } |
63 changes: 63 additions & 0 deletions
63
...t/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
|
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(), | ||
| ) | ||
| } | ||
| } | ||
9 changes: 9 additions & 0 deletions
9
...in/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
93 changes: 93 additions & 0 deletions
93
...apter-out/src/test/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapterTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package hs.kr.entrydsm.configuration.adapterout | ||
|
|
||
| import hs.kr.entrydsm.configuration.domain.document.exception.StorageUnavailableException | ||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
| import software.amazon.awssdk.core.exception.SdkClientException | ||
| import software.amazon.awssdk.services.s3.S3Client | ||
| import software.amazon.awssdk.services.s3.model.DeleteObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.DeleteObjectResponse | ||
| import software.amazon.awssdk.services.s3.model.HeadObjectRequest | ||
| import software.amazon.awssdk.services.s3.model.HeadObjectResponse | ||
| import software.amazon.awssdk.services.s3.model.NoSuchKeyException | ||
| import software.amazon.awssdk.services.s3.model.S3Exception | ||
| import software.amazon.awssdk.services.s3.presigner.S3Presigner | ||
| import java.lang.reflect.Proxy | ||
|
|
||
| class S3StorageAdapterTest { | ||
|
|
||
| @Test | ||
| fun `객체가 있으면 존재한다고 판단한다`() { | ||
| assertTrue(adapter(FakeS3Client()).exists("photo/a.jpg")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `본문 없는 404 응답도 객체 없음으로 처리한다`() { | ||
| val client = FakeS3Client(headFailure = s3Exception(404)) | ||
|
|
||
| assertFalse(adapter(client).exists("photo/a.jpg")) | ||
| } | ||
|
|
||
| @Test | ||
| fun `NoSuchKey 예외도 객체 없음으로 처리한다`() { | ||
| val client = FakeS3Client(headFailure = NoSuchKeyException.builder().statusCode(404).build()) | ||
|
|
||
| assertFalse(adapter(client).exists("photo/a.jpg")) | ||
| } | ||
|
|
||
| @Test(expected = StorageUnavailableException::class) | ||
| fun `403 응답은 스토리지 오류로 올린다`() { | ||
| adapter(FakeS3Client(headFailure = s3Exception(403))).exists("photo/a.jpg") | ||
| } | ||
|
|
||
| @Test(expected = StorageUnavailableException::class) | ||
| fun `네트워크 오류는 스토리지 오류로 올린다`() { | ||
| val client = FakeS3Client(headFailure = SdkClientException.builder().message("connect timed out").build()) | ||
|
|
||
| adapter(client).exists("photo/a.jpg") | ||
| } | ||
|
|
||
| @Test(expected = StorageUnavailableException::class) | ||
| fun `삭제 실패도 스토리지 오류로 올린다`() { | ||
| val client = FakeS3Client(deleteFailure = s3Exception(500)) | ||
|
|
||
| adapter(client).delete("photo/a.jpg") | ||
| } | ||
|
|
||
| @Test | ||
| fun `삭제가 성공하면 예외를 던지지 않는다`() { | ||
| adapter(FakeS3Client()).delete("photo/a.jpg") | ||
| } | ||
|
|
||
| private fun adapter(client: S3Client) = S3StorageAdapter(client, stubPresigner(), "entrydsm") | ||
|
|
||
| // presign 은 이 테스트에서 쓰지 않는다. 메서드가 7개라 프록시로 대신한다. | ||
| private fun stubPresigner(): S3Presigner = | ||
| Proxy.newProxyInstance( | ||
| S3Presigner::class.java.classLoader, | ||
| arrayOf(S3Presigner::class.java), | ||
| ) { _, _, _ -> throw UnsupportedOperationException() } as S3Presigner | ||
|
|
||
| private fun s3Exception(statusCode: Int): S3Exception = | ||
| S3Exception.builder().statusCode(statusCode).message("status=$statusCode").build() as S3Exception | ||
|
|
||
| private class FakeS3Client( | ||
| private val headFailure: RuntimeException? = null, | ||
| private val deleteFailure: RuntimeException? = null, | ||
| ) : S3Client { | ||
| override fun serviceName(): String = "s3" | ||
|
|
||
| override fun close() = Unit | ||
|
|
||
| override fun headObject(request: HeadObjectRequest): HeadObjectResponse { | ||
| headFailure?.let { throw it } | ||
| return HeadObjectResponse.builder().build() | ||
| } | ||
|
|
||
| override fun deleteObject(request: DeleteObjectRequest): DeleteObjectResponse { | ||
| deleteFailure?.let { throw it } | ||
| return DeleteObjectResponse.builder().build() | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.