pricelees 6bcec4c0ed [#44] 매장 기능 도입 (#45)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #44

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 매장 기능 도입 및 기존 기능에 적용
- 관리자 타입(본사, 매장, 전체) 분리 및 API별 적용

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 신규 기능 및 매장 기능 도입으로 수정된 기존 API 모두 통합 테스트 완료

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->
- 아직 미결제 예약 스케쥴링 작업 등 추가적인 작업이 필요하긴 하지만, 이 작업들은 배포 후 추가로 진행할 예정
- 다음 작업은 배포 + 초기 데이터 삽입

Reviewed-on: #45
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-20 03:15:06 +00:00

169 lines
5.7 KiB
Kotlin

package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.client.ResponseErrorHandler
import org.springframework.web.client.RestClient
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import java.net.URI
private val log: KLogger = KotlinLogging.logger {}
@Component
class TosspayClient(
objectMapper: ObjectMapper,
tosspayClientBuilder: RestClient.Builder
) {
private val confirmClient = ConfirmClient(objectMapper, tosspayClientBuilder.build())
private val cancelClient = CancelClient(objectMapper, tosspayClientBuilder.build())
fun confirm(
paymentKey: String,
orderId: String,
amount: Int,
): PaymentClientConfirmResponse {
val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
return confirmClient.request(paymentKey, orderId, amount)
.also {
log.info { "[TosspayClient.confirm] 결제 승인 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
}
}
fun cancel(
paymentKey: String,
amount: Int,
cancelReason: String
): PaymentClientCancelResponse {
val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
return cancelClient.request(paymentKey, amount, cancelReason).also {
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
}
}
}
private class ConfirmClient(
private val objectMapper: ObjectMapper,
private val client: RestClient,
) {
companion object {
private const val CONFIRM_URI: String = "/v1/payments/confirm"
}
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
fun request(paymentKey: String, orderId: String, amount: Int): PaymentClientConfirmResponse {
val response = client.post()
.uri(CONFIRM_URI)
.contentType(MediaType.APPLICATION_JSON)
.body(
mapOf(
"paymentKey" to paymentKey,
"orderId" to orderId,
"amount" to amount
)
)
.retrieve()
.onStatus(errorHandler)
.body(String::class.java)
?: run {
log.error { "[TosspayClient] 응답 바디 변환 실패" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" }
return objectMapper.readValue(response, PaymentClientConfirmResponse::class.java)
}
}
private class CancelClient(
private val objectMapper: ObjectMapper,
private val client: RestClient,
) {
companion object {
private const val CANCEL_URI: String = "/v1/payments/{paymentKey}/cancel"
}
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
fun request(
paymentKey: String,
amount: Int,
cancelReason: String
): PaymentClientCancelResponse {
val response = client.post()
.uri(CANCEL_URI, paymentKey)
.body(
mapOf(
"cancelReason" to cancelReason,
"cancelAmount" to amount,
)
)
.retrieve()
.onStatus(errorHandler)
.body(String::class.java)
?: run {
log.error { "[TosspayClient] 응답 바디 변환 실패" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" }
return objectMapper.readValue(response, PaymentClientCancelResponse::class.java)
}
}
private class TosspayErrorHandler(
private val objectMapper: ObjectMapper
) : ResponseErrorHandler {
override fun hasError(response: ClientHttpResponse): Boolean {
val statusCode: HttpStatusCode = response.statusCode
return statusCode.is4xxClientError || statusCode.is5xxServerError
}
override fun handleError(
url: URI,
method: HttpMethod,
response: ClientHttpResponse
): Nothing {
val requestType: String = paymentRequestType(url)
log.warn { "[TosspayClient] $requestType 요청 실패: response: ${parseResponse(response)}" }
throw PaymentException(paymentErrorCode(response.statusCode))
}
private fun paymentRequestType(url: URI): String {
val type = url.path.split("/").last()
if (type == "cancel") {
return "취소"
}
return "승인"
}
private fun paymentErrorCode(statusCode: HttpStatusCode) = if (statusCode.is4xxClientError) {
PaymentErrorCode.PAYMENT_CLIENT_ERROR
} else {
PaymentErrorCode.PAYMENT_PROVIDER_ERROR
}
private fun parseResponse(response: ClientHttpResponse): TosspayErrorResponse {
val body = response.body
return objectMapper.readValue(body, TosspayErrorResponse::class.java).also {
body.close()
}
}
}