From 0dd50e2d993abcb4ea7817138336bb6c133458df Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:41:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20DTO=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=EC=97=90=20=EB=A7=9E=EC=B6=98=20TosspaymentC?= =?UTF-8?q?lient=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/v2/TosspaymentClientV2.kt | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt new file mode 100644 index 00000000..0d0310f7 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt @@ -0,0 +1,133 @@ +package roomescape.payment.infrastructure.client.v2 + +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 roomescape.payment.infrastructure.client.TossPaymentErrorResponse +import java.net.URI + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class TosspaymentClientV2( + objectMapper: ObjectMapper, + tossPaymentClientBuilder: RestClient.Builder +) { + private val confirmClient = ConfirmClient(objectMapper, tossPaymentClientBuilder.build()) + private val cancelClient = CancelClient(objectMapper, tossPaymentClientBuilder.build()) + + fun confirm(request: PaymentConfirmRequest): PaymentConfirmResponse { + log.info { "[TossPaymentClientV2.confirm] 승인 요청: request=$request" } + + return confirmClient.request(request) + } + + fun cancel(request: PaymentCancelRequestV2): PaymentCancelResponseV2 { + log.info { "[TossPaymentClient.cancel] 취소 요청: request=$request" } + + return cancelClient.request(request) + } +} + +private class ConfirmClient( + 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(request: PaymentConfirmRequest): PaymentConfirmResponse = client.post() + .uri(CONFIRM_URI) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .onStatus(errorHandler) + .body(PaymentConfirmResponse::class.java) + ?: run { + log.error { "[TossPaymentConfirmClient.request] 응답 바디 변환 실패" } + throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } +} + +private class CancelClient( + 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(request: PaymentCancelRequestV2): PaymentCancelResponseV2 = client.post() + .uri(CANCEL_URI, request.paymentKey) + .body( + mapOf( + "cancelReason" to request.cancelReason, + "cancelAmount" to request.amount, + ) + ) + .retrieve() + .onStatus(errorHandler) + .body(PaymentCancelResponseV2::class.java) + ?: run { + log.error { "[TossPaymentClient] 응답 바디 변환 실패" } + throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } +} + +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 { "[TossPaymentClient] $requestType 요청 실패: response: ${toErrorResponse(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 toErrorResponse(response: ClientHttpResponse): TossPaymentErrorResponse { + val body = response.body + + return objectMapper.readValue(body, TossPaymentErrorResponse::class.java).also { + body.close() + } + } +}