diff --git a/.gitignore b/.gitignore index 7b8725e..34f2266 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ src/test/generated/ ### env ### .env + +### key ### +*.pem diff --git a/build.gradle.kts b/build.gradle.kts index adf18a2..2865e3a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,9 @@ dependencies { implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") + implementation("com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.77.2") + implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.77.2") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") testImplementation("com.h2database:h2") diff --git a/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt b/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt index 24ecde5..d7d8c71 100644 --- a/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt +++ b/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt @@ -10,13 +10,13 @@ import land.leets.domain.application.domain.Application import land.leets.domain.application.presentation.dto.ApplicationDetailsResponse import land.leets.domain.application.presentation.dto.ApplicationRequest import land.leets.domain.application.presentation.dto.ApplicationResponse +import land.leets.domain.application.presentation.dto.ApplicationStatusResponse import land.leets.domain.application.presentation.dto.StatusRequest import land.leets.domain.application.usecase.* import land.leets.domain.auth.AuthDetails import land.leets.global.error.ErrorResponse import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* -import java.util.* @RestController @RequestMapping("/application") @@ -25,7 +25,8 @@ class ApplicationController( private val updateApplication: UpdateApplication, private val getApplication: GetAllApplication, private val getApplicationDetails: GetApplicationDetails, - private val updateResult: UpdateResult + private val updateResult: UpdateResult, + private val getApplicationStatus: GetApplicationStatus, ) { @Operation(summary = "(유저) 지원서 작성", description = "지원서를 작성합니다.") @@ -116,4 +117,18 @@ class ApplicationController( val uid = authDetails.uid return getApplicationDetails.execute(uid) } + + @Operation(summary = "(유저) 지원서 상태 불러오기", description = "작성한 지원서 상태를 불러옵니다.") + @ApiResponses( + ApiResponse(responseCode = "200"), + ApiResponse(responseCode = "400", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "403", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "404", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "500", content = [Content(schema = Schema(implementation = ErrorResponse::class))]) + ) + @GetMapping("/status") + fun getStatus(@AuthenticationPrincipal authDetails: AuthDetails): ApplicationStatusResponse { + val uid = authDetails.uid + return getApplicationStatus.execute(uid) + } } diff --git a/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt b/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt new file mode 100644 index 0000000..ad3b426 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt @@ -0,0 +1,39 @@ +package land.leets.domain.application.presentation.dto + +import land.leets.domain.application.domain.Application +import land.leets.domain.application.type.ApplicationStatus +import land.leets.domain.interview.domain.Interview +import land.leets.domain.interview.type.HasInterview +import java.time.LocalDateTime + +data class ApplicationStatusResponse( + val id: Long, + val status: ApplicationStatus, + val hasInterview: HasInterview?, + val interviewDate: LocalDateTime?, + val interviewPlace: String?, +) { + companion object { + fun of( + application: Application, + interview: Interview?, + ): ApplicationStatusResponse { + if (application.applicationStatus != ApplicationStatus.PASS_PAPER) { + return ApplicationStatusResponse( + id = application.id!!, + status = application.applicationStatus, + hasInterview = null, + interviewDate = null, + interviewPlace = null, + ) + } + return ApplicationStatusResponse( + id = application.id!!, + status = application.applicationStatus, + hasInterview = interview!!.hasInterview, + interviewDate = interview.fixedInterviewDate, + interviewPlace = interview.place, + ) + } + } +} diff --git a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt new file mode 100644 index 0000000..488d57c --- /dev/null +++ b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt @@ -0,0 +1,8 @@ +package land.leets.domain.application.usecase + +import land.leets.domain.application.presentation.dto.ApplicationStatusResponse +import java.util.UUID + +interface GetApplicationStatus { + fun execute(uid: UUID): ApplicationStatusResponse +} diff --git a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt new file mode 100644 index 0000000..b4788be --- /dev/null +++ b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt @@ -0,0 +1,24 @@ +package land.leets.domain.application.usecase + +import land.leets.domain.application.domain.repository.ApplicationRepository +import land.leets.domain.application.exception.ApplicationNotFoundException +import land.leets.domain.application.presentation.dto.ApplicationStatusResponse +import land.leets.domain.interview.domain.repository.InterviewRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +@Transactional(readOnly = true) +class GetApplicationStatusImpl( + private val applicationRepository: ApplicationRepository, + private val interviewRepository: InterviewRepository, +) : GetApplicationStatus { + override fun execute(uid: UUID): ApplicationStatusResponse { + val application = applicationRepository.findByUser_Id(uid) + ?: throw ApplicationNotFoundException() + val interview = interviewRepository.findByApplication(application) + + return ApplicationStatusResponse.of(application, interview) + } +} diff --git a/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt new file mode 100644 index 0000000..dbd22d5 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt @@ -0,0 +1,41 @@ +package land.leets.domain.storage.presentation + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.validation.constraints.NotBlank +import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse +import land.leets.domain.storage.usecase.GetPreAuthenticatedUrl +import land.leets.global.error.ErrorResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/storages") +class StorageController( + private val getPreAuthenticatedUrl: GetPreAuthenticatedUrl +) { + + @Operation(summary = "Pre-Authenticated Url 발급", description = "OCI Object Storage에 파일 업로드를 위한 Pre-Authenticated Url을 발급합니다.") + @ApiResponses( + ApiResponse(responseCode = "200"), + ApiResponse(responseCode = "400", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "403", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "404", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "500", content = [Content(schema = Schema(implementation = ErrorResponse::class))]) + ) + @PostMapping("/pre-authenticated-url") + fun getPreAuthenticatedUrl( + @NotBlank + @Parameter(description = "버킷에 업로드할 파일 이름 및 경로", example = "profile/{username}.jpg") + @RequestParam fileName: String + ): ResponseEntity { + return ResponseEntity.ok(getPreAuthenticatedUrl.execute(fileName)) + } +} diff --git a/src/main/kotlin/land/leets/domain/storage/presentation/dto/PreAuthenticatedUrlResponse.kt b/src/main/kotlin/land/leets/domain/storage/presentation/dto/PreAuthenticatedUrlResponse.kt new file mode 100644 index 0000000..84f70c2 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/presentation/dto/PreAuthenticatedUrlResponse.kt @@ -0,0 +1,5 @@ +package land.leets.domain.storage.presentation.dto + +data class PreAuthenticatedUrlResponse( + val url: String, +) diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrl.kt new file mode 100644 index 0000000..6a4baf1 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrl.kt @@ -0,0 +1,5 @@ +package land.leets.domain.storage.usecase + +interface GetObjectUrl { + fun execute(fileName: String): String +} diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrlImpl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrlImpl.kt new file mode 100644 index 0000000..0d85ff7 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrlImpl.kt @@ -0,0 +1,15 @@ +package land.leets.domain.storage.usecase + +import com.oracle.bmc.objectstorage.ObjectStorage +import org.springframework.beans.factory.annotation.Value + + +class GetObjectUrlImpl( + private val objectStorage: ObjectStorage, + @Value("\${oci.bucket.name}") private val bucketName: String, + @Value("\${oci.bucket.namespace}") private val bucketNamespace: String, +) : GetObjectUrl { + + override fun execute(fileName: String): String = + "${objectStorage.endpoint}/n/$bucketNamespace/b/$bucketName/o/$fileName" +} diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrl.kt new file mode 100644 index 0000000..0e10371 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrl.kt @@ -0,0 +1,7 @@ +package land.leets.domain.storage.usecase + +import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse + +interface GetPreAuthenticatedUrl { + fun execute(fileName: String): PreAuthenticatedUrlResponse +} diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt new file mode 100644 index 0000000..d3595d3 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt @@ -0,0 +1,71 @@ +package land.leets.domain.storage.usecase + +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails +import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest +import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + +private const val PRESIGNED_URL_EXPIRATION_MINUTES = 15L +private const val PRESIGNED_REQUEST_NAME_PREFIX = "PAR_Request_" + +@Service +class GetPreAuthenticatedUrlImpl( + private val objectStorage: ObjectStorage, + @Value("\${oci.bucket.name}") private val bucketName: String, + @Value("\${oci.bucket.namespace}") private val bucketNamespace: String, +) : GetPreAuthenticatedUrl { + + override fun execute(fileName: String): PreAuthenticatedUrlResponse { + val uniqueFileName = generateUniqueFileName(fileName) + val expirationTime = calculateExpirationTime() + + val details = buildPreAuthenticatedRequestDetails(uniqueFileName, expirationTime) + val request = buildPreAuthenticatedRequest(details) + + val response = objectStorage.createPreauthenticatedRequest(request) + val fullUrl = buildFullUrl(response.preauthenticatedRequest.accessUri) + + return PreAuthenticatedUrlResponse(fullUrl) + } + + private fun generateUniqueFileName(fileName: String): String { + val uuid = UUID.randomUUID() + + return if ('/' in fileName) { + "${fileName.substringBeforeLast('/')}/${uuid}_${fileName.substringAfterLast('/')}" + } else { + "${uuid}_$fileName" + } + } + + private fun calculateExpirationTime(): Date = + Date.from(Instant.now().plus(PRESIGNED_URL_EXPIRATION_MINUTES, ChronoUnit.MINUTES)) + + private fun buildPreAuthenticatedRequestDetails( + fileName: String, + expirationTime: Date + ): CreatePreauthenticatedRequestDetails = + CreatePreauthenticatedRequestDetails.builder().apply { + name("$PRESIGNED_REQUEST_NAME_PREFIX${UUID.randomUUID()}") + objectName(fileName) + accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) + timeExpires(expirationTime) + }.build() + + private fun buildPreAuthenticatedRequest( + details: CreatePreauthenticatedRequestDetails + ): CreatePreauthenticatedRequestRequest = + CreatePreauthenticatedRequestRequest.builder().apply { + namespaceName(bucketNamespace) + bucketName(bucketName) + createPreauthenticatedRequestDetails(details) + }.build() + + private fun buildFullUrl(accessUri: String): String = + "${objectStorage.endpoint}$accessUri" +} diff --git a/src/main/kotlin/land/leets/global/config/OciConfig.kt b/src/main/kotlin/land/leets/global/config/OciConfig.kt new file mode 100644 index 0000000..94d2446 --- /dev/null +++ b/src/main/kotlin/land/leets/global/config/OciConfig.kt @@ -0,0 +1,49 @@ +package land.leets.global.config + +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.ObjectStorageClient +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.io.FileInputStream +import java.io.InputStream +import java.util.function.Supplier + +@Configuration +class OciConfig( + @Value("\${oci.tenant-id}") + private val tenantId: String, + @Value("\${oci.user-id}") + private val userId: String, + @Value("\${oci.fingerprint}") + private val fingerprint: String, + @Value("\${oci.private-key-path}") + private val privateKeyPath: String, + @Value("\${oci.region}") + private val region: String, + @Value("\${oci.bucket.namespace}") + private val bucketNamespace: String +) { + + @Bean + fun objectStorageClient(): ObjectStorage = + createStorageClient(createAuthenticationProvider()) + .also { client -> + client.endpoint = "https://$bucketNamespace.objectstorage.$region.oci.customer-oci.com" + } + + private fun createAuthenticationProvider(): SimpleAuthenticationDetailsProvider = + SimpleAuthenticationDetailsProvider.builder().apply { + tenantId(tenantId) + userId(userId) + fingerprint(fingerprint) + privateKeySupplier(createPrivateKeySupplier()) + }.build() + + private fun createPrivateKeySupplier(): Supplier = + Supplier { FileInputStream(privateKeyPath) } + + private fun createStorageClient(provider: SimpleAuthenticationDetailsProvider): ObjectStorage = + ObjectStorageClient.builder().build(provider) +} diff --git a/src/main/kotlin/land/leets/global/config/SecurityConfig.kt b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt index 4e47c22..cca7061 100644 --- a/src/main/kotlin/land/leets/global/config/SecurityConfig.kt +++ b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt @@ -98,6 +98,9 @@ class SecurityConfig( // images authorize(HttpMethod.GET, "/images/{imageName}", permitAll) + // storages + authorize(HttpMethod.POST, "/storages/pre-authenticated-url", hasAnyAuthority(AuthRole.ROLE_USER.role)) + // default authorize(anyRequest, denyAll) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f0b4b94..fc60f72 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,3 +61,13 @@ target: image: path: images/ +oci: + tenant-id: ${OCI_TENANT_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key-path: ${OCI_PRIVATE_KEY_PATH} + region: ${OCI_REGION} + bucket: + name: ${OCI_BUCKET_NAME} + namespace: ${OCI_BUCKET_NAMESPACE} + diff --git a/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt b/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt new file mode 100644 index 0000000..7afbe7f --- /dev/null +++ b/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt @@ -0,0 +1,97 @@ +package land.leets.domain.application.usecase + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import land.leets.domain.application.domain.Application +import land.leets.domain.application.domain.repository.ApplicationRepository +import land.leets.domain.application.exception.ApplicationNotFoundException +import land.leets.domain.application.type.ApplicationStatus +import land.leets.domain.interview.domain.Interview +import land.leets.domain.interview.domain.repository.InterviewRepository +import land.leets.domain.interview.type.HasInterview +import java.time.LocalDateTime +import java.util.UUID + +class GetApplicationStatusImplTest : DescribeSpec({ + + val applicationRepository = mockk() + val interviewRepository = mockk() + val getApplicationStatus = GetApplicationStatusImpl(applicationRepository, interviewRepository) + + val uid = UUID.randomUUID() + + val interviewDate: LocalDateTime = LocalDateTime.of(2026, 3, 14, 14, 0) + val interviewPlace = "전자정보도서관 1층 스터디룸 A" + val applicationId = 1L + + fun mockApplication( + status: ApplicationStatus, + ): Application = mockk().also { application -> + every { application.id } returns applicationId + every { application.applicationStatus } returns status + } + + fun mockInterview( + hasInterview: HasInterview = HasInterview.PENDING, + ): Interview = mockk().also { interview -> + every { interview.hasInterview } returns hasInterview + every { interview.fixedInterviewDate } returns interviewDate + every { interview.place } returns interviewPlace + } + + describe("GetApplicationStatusImpl 유스케이스는") { + + context("지원서 상태 조회를 요청할 때") { + + it("지원서 상태가 PASS_PAPER이면 인터뷰 정보를 포함하여 반환한다") { + val application = mockApplication(status = ApplicationStatus.PASS_PAPER) + val interview = mockInterview() + every { applicationRepository.findByUser_Id(uid) } returns application + every { interviewRepository.findByApplication(application) } returns interview + + val result = getApplicationStatus.execute(uid) + + result.id shouldBe 1L + result.status shouldBe ApplicationStatus.PASS_PAPER + result.hasInterview shouldBe HasInterview.PENDING + result.interviewDate shouldBe interviewDate + result.interviewPlace shouldBe interviewPlace + } + + it("PASS_PAPER이 아닌 상태들은 인터뷰 정보가 null이어야 한다") { + val nonInterviewStatuses = listOf( + ApplicationStatus.PENDING, + ApplicationStatus.FAIL_PAPER, + ApplicationStatus.PASS, + ApplicationStatus.FAIL, + ) + + nonInterviewStatuses.forEach { status -> + val application = mockApplication(status = status) + val interview = mockInterview() + every { applicationRepository.findByUser_Id(uid) } returns application + every { interviewRepository.findByApplication(application) } returns interview + + val result = getApplicationStatus.execute(uid) + + result.id shouldBe 1L + result.status shouldBe status + result.hasInterview shouldBe null + result.interviewDate shouldBe null + result.interviewPlace shouldBe null + } + } + + it("지원서가 존재하지 않으면 ApplicationNotFoundException을 던진다") { + every { applicationRepository.findByUser_Id(uid) } returns null + + shouldThrow { + getApplicationStatus.execute(uid) + } + } + } + } +}) diff --git a/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt new file mode 100644 index 0000000..551bd8d --- /dev/null +++ b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt @@ -0,0 +1,43 @@ +package land.leets.domain.storage.usecase + +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.model.PreauthenticatedRequest +import com.oracle.bmc.objectstorage.responses.CreatePreauthenticatedRequestResponse +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetPreAuthenticatedUrlImplTest : DescribeSpec({ + + val objectStorage = mockk() + val bucketName = "test-bucket" + val namespaceName = "test-namespace" + val region = "ap-seoul-1" + val endpoint = "https://$namespaceName.objectstorage.$region.oci.customer-oci.com" + + val getPreAuthenticatedUrlImpl = GetPreAuthenticatedUrlImpl(objectStorage, bucketName, namespaceName) + + describe("GetPreAuthenticatedUrl") { + context("Presigned URL 생성을 요청할 때") { + val fileName = "test.jpg" + val accessUri = "/p/some-random-string/b/bucket/o/test.jpg" + + val request = PreauthenticatedRequest.builder().accessUri(accessUri).build() + val response = CreatePreauthenticatedRequestResponse.builder() + .preauthenticatedRequest(request) + .build() + + every { objectStorage.createPreauthenticatedRequest(any()) } returns response + every { objectStorage.endpoint } returns endpoint + + it("객체 업로드 권한이 있는 전체 URL을 반환한다") { + val result = getPreAuthenticatedUrlImpl.execute(fileName) + + result.url shouldBe "$endpoint$accessUri" + verify { objectStorage.createPreauthenticatedRequest(any()) } + } + } + } +})