Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6c61ddf
chore: `.pem` ignore 처리
yechan-kim Dec 29, 2025
ae47688
chore: oci object storage 관련 의존성 추가
yechan-kim Dec 29, 2025
33dcfda
chore: oci object storage 관련 profile 추가
yechan-kim Dec 29, 2025
7906330
feat: oci object storage 설정 추가
yechan-kim Dec 29, 2025
6a95069
feat: Pre-Authenticated Url 응답 DTO 추가
yechan-kim Dec 29, 2025
6b6e5d5
feat: Pre-Authenticated Url 생성 기능 구현
yechan-kim Dec 29, 2025
cee2430
feat: oci object storage 의 object 주소를 반환하는 기능 추가
yechan-kim Dec 29, 2025
b06b1db
feat: oci object storage Rest 컨트롤러 추가
yechan-kim Dec 29, 2025
b3aad05
feat: oci object storage API 보안 설정 추가
yechan-kim Dec 29, 2025
2343a16
delete: 불필요한 의존성 삭제
yechan-kim Dec 29, 2025
d20c197
test: Pre-Authenticated Url 생성 기능에 대한 테스트 코드 작성
yechan-kim Dec 29, 2025
12b8a5e
delete: 미사용 코드 삭제
yechan-kim Dec 31, 2025
607cf83
fix: 오탈자 수정
yechan-kim Dec 31, 2025
dae92e8
feat: 파라미터 검증 로직 추가
yechan-kim Dec 31, 2025
f770890
refactor: 표현 단순화
yechan-kim Dec 31, 2025
4bb8573
fix: 오탈자 수정
yechan-kim Jan 1, 2026
57fdf9c
refactor: 상수를 top-level로 이동
yechan-kim Jan 1, 2026
777feb7
feat: 지원서 상태 조회 API 및 DTO 구현
jwnnoh Dec 30, 2025
697d922
feat: 지원서 상태 조회 기능 구현
jwnnoh Dec 30, 2025
04047c1
test: 지원서 상태 조회 기능 테스트 구현
jwnnoh Dec 30, 2025
da6f028
fix: 비즈니스 로직에 따른 인터뷰 일정 및 장소 전달 방식 수정
jwnnoh Dec 31, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ src/test/generated/

### env ###
.env

### key ###
*.pem
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 = "지원서를 작성합니다.")
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<PreAuthenticatedUrlResponse> {
return ResponseEntity.ok(getPreAuthenticatedUrl.execute(fileName))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package land.leets.domain.storage.presentation.dto

data class PreAuthenticatedUrlResponse(
val url: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package land.leets.domain.storage.usecase

interface GetObjectUrl {
fun execute(fileName: String): String
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
}
49 changes: 49 additions & 0 deletions src/main/kotlin/land/leets/global/config/OciConfig.kt
Original file line number Diff line number Diff line change
@@ -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<InputStream> =
Supplier { FileInputStream(privateKeyPath) }

private fun createStorageClient(provider: SimpleAuthenticationDetailsProvider): ObjectStorage =
ObjectStorageClient.builder().build(provider)
}
3 changes: 3 additions & 0 deletions src/main/kotlin/land/leets/global/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Loading