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() } } }