-
Notifications
You must be signed in to change notification settings - Fork 1
#164 feat payment refund api #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
be85c65
528911f
543c7ca
1aeade0
028d3a7
2e4422f
d8390a5
5a36b49
e557cfb
96c9f7f
8f689f0
105e722
1080027
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,30 +5,50 @@ | |
| import java.nio.charset.StandardCharsets; | ||
| import java.util.Base64; | ||
| import java.util.Map; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.http.HttpHeaders; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.http.client.SimpleClientHttpRequestFactory; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.client.HttpClientErrorException; | ||
| import org.springframework.web.client.RestClient; | ||
|
|
||
| import io.github.resilience4j.bulkhead.BulkheadFullException; | ||
| import io.github.resilience4j.bulkhead.annotation.Bulkhead; | ||
| import io.github.resilience4j.circuitbreaker.CallNotPermittedException; | ||
| import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @Profile("prod") | ||
| @Profile({"prod","test"}) | ||
| public class TossPaymentProvider implements PaymentProvider { | ||
|
|
||
| private final RestClient restClient; | ||
| private final ConcurrentHashMap<String, String> paymentKeyStore = new ConcurrentHashMap<>(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
|
Comment on lines
30
to
32
|
||
| public TossPaymentProvider(RestClient.Builder builder, @Value("${toss.secret-key}") String secretKey) { | ||
| public TossPaymentProvider( | ||
| RestClient.Builder builder, | ||
| @Value("${toss.secret-key}") String secretKey, | ||
| @Value("${toss.base-url:https://api.tosspayments.com}") String baseUrl | ||
| ) { | ||
| if (secretKey == null || secretKey.isEmpty()) { | ||
| throw new IllegalStateException("toss.secret-key๊ฐ ์ค์ ๋์ด ์์ง ์์ต๋๋ค."); | ||
| } | ||
| String encoded = Base64.getEncoder() | ||
| .encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8)); | ||
|
|
||
| var factory = new SimpleClientHttpRequestFactory(); | ||
| factory.setConnectTimeout(2_000); | ||
| factory.setReadTimeout(3_000); | ||
|
|
||
| this.restClient = builder | ||
| .baseUrl("https://api.tosspayments.com") | ||
| .baseUrl(baseUrl) | ||
| .defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + encoded) | ||
| .requestFactory(factory) | ||
| .build(); | ||
| } | ||
|
|
||
|
|
@@ -38,6 +58,8 @@ public boolean supports(String providerName) { | |
| } | ||
|
|
||
| @Override | ||
| @CircuitBreaker(name = "tossConfirm", fallbackMethod = "confirmFallback") | ||
| @Bulkhead(name = "tossConfirm") | ||
| public ConfirmResponse confirm(ConfirmRequest request) { | ||
| Map response = restClient.post() | ||
| .uri("/v1/payments/confirm") | ||
|
|
@@ -52,11 +74,61 @@ public ConfirmResponse confirm(ConfirmRequest request) { | |
|
|
||
| String status = (String)response.getOrDefault("status", ""); | ||
| if ("DONE".equals(status)) { | ||
| paymentKeyStore.put(request.orderId(), request.paymentKey()); | ||
| int totalAmount = (int)response.getOrDefault("totalAmount", request.amount()); | ||
| return new ConfirmResponse(true, totalAmount, null, null); | ||
| } | ||
| String code = (String)response.getOrDefault("code", "TOSS_ERROR"); | ||
| String msg = (String)response.getOrDefault("message", "ํ ์ค ์น์ธ ์คํจ"); | ||
| return new ConfirmResponse(false, 0, code, msg); | ||
| } | ||
|
|
||
| private ConfirmResponse confirmFallback(ConfirmRequest request, CallNotPermittedException e) { | ||
| log.warn("Toss ๊ฒฐ์ ์น์ธ ์ฐจ๋จ๋จ [CIRCUIT_BREAKER_OPEN]"); | ||
| return new ConfirmResponse(false, 0, "PG_TEMPORARY_UNAVAILABLE", "๊ฒฐ์ ์๋น์ค๊ฐ ์ผ์์ ์ผ๋ก ๋ถ์์ ํฉ๋๋ค."); | ||
| } | ||
|
|
||
| private ConfirmResponse confirmFallback(ConfirmRequest request, BulkheadFullException e) { | ||
| log.warn("Toss ๊ฒฐ์ ์น์ธ ์ฐจ๋จ๋จ [BULKHEAD_FULL]"); | ||
| return new ConfirmResponse(false, 0, "PG_TEMPORARY_UNAVAILABLE", "๊ฒฐ์ ์์ฒญ์ด ์ง์ค๋๊ณ ์์ต๋๋ค."); | ||
| } | ||
|
|
||
| private ConfirmResponse confirmFallback(ConfirmRequest request, Throwable e) { | ||
| log.warn("Toss ๊ฒฐ์ ์น์ธ ์คํจ: {}", e.toString()); | ||
| return new ConfirmResponse(false, 0, "PG_NETWORK_ERROR", "PG ํต์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค."); | ||
| } | ||
|
|
||
| @Override | ||
| public CancelResponse cancel(CancelRequest request) { | ||
|
Comment on lines
+101
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
@Override
@CircuitBreaker(name = "tossCancel", fallbackMethod = "cancelFallback")
@Bulkhead(name = "tossCancel")
public CancelResponse cancel(CancelRequest request) { |
||
| String paymentKey = paymentKeyStore.get(request.orderId()); | ||
| if (paymentKey == null) { | ||
| return new CancelResponse(false, 0, null, "PAYMENT_KEY_NOT_FOUND", | ||
| "๊ฒฐ์ ํค๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค. orderId=" + request.orderId()); | ||
| } | ||
|
|
||
| try { | ||
| Map response = restClient.post() | ||
| .uri("/v1/payments/{paymentKey}/cancel", paymentKey) | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .body(Map.of( | ||
| "cancelReason", request.cancelReason(), | ||
| "cancelAmount", request.cancelAmount()) | ||
| ) | ||
| .retrieve() | ||
| .body(Map.class); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Toss API์ JSON ์๋ต์ |
||
|
|
||
| String status = (String)response.getOrDefault("status", ""); | ||
| if ("CANCELED".equals(status) || "PARTIAL_CANCELED".equals(status)) { | ||
| String transactionKey = (String)response.getOrDefault("transactionKey", ""); | ||
| return new CancelResponse(true, request.cancelAmount(), transactionKey, null, null); | ||
| } | ||
|
|
||
| String code = (String)response.getOrDefault("code", "TOSS_ERROR"); | ||
| String msg = (String)response.getOrDefault("message", "ํ ์ค ์ทจ์ ์คํจ"); | ||
| return new CancelResponse(false, 0, null, code, msg); | ||
| } catch (HttpClientErrorException e) { | ||
| log.error("ํ ์ค ์ทจ์ API ํธ์ถ ์คํจ - orderId: {}, error: {}", request.orderId(), e.getResponseBodyAsString()); | ||
| return new CancelResponse(false, 0, null, "TOSS_CANCEL_ERROR", e.getResponseBodyAsString()); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package wisoft.nextframe.paymentgateway.api; | ||
|
|
||
| import static org.mockito.BDDMockito.*; | ||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; | ||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | ||
| import static wisoft.nextframe.paymentgateway.api.PaymentGatewayController.*; | ||
|
|
||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.test.context.bean.override.mockito.MockitoBean; | ||
| import org.springframework.test.web.servlet.MockMvc; | ||
|
|
||
| import wisoft.nextframe.paymentgateway.provider.ProviderRouter; | ||
|
|
||
| @WebMvcTest(PaymentGatewayController.class) | ||
| @DisplayName("PaymentGatewayController ํ ์คํธ") | ||
| class PaymentGatewayControllerTest { | ||
|
|
||
| @Autowired | ||
| private MockMvc mockMvc; | ||
|
|
||
| @MockitoBean | ||
| private ProviderRouter router; | ||
|
|
||
| @Test | ||
| @DisplayName("POST /payments/confirm ์ฑ๊ณต ์ 200 OK๋ฅผ ๋ฐํํ๋ค") | ||
| void confirm_success() throws Exception { | ||
| given(router.confirm(eq("toss"), any(ConfirmRequest.class))) | ||
| .willReturn(new ConfirmResponse(true, 10000, null, null)); | ||
|
|
||
| mockMvc.perform(post("/api/v1/gateway/payments/confirm") | ||
| .param("provider", "toss") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"paymentKey": "pk_123", "orderId": "order-1", "amount": 10000} | ||
| """)) | ||
| .andExpect(status().isOk()) | ||
| .andExpect(jsonPath("$.isSuccess").value(true)) | ||
| .andExpect(jsonPath("$.totalAmount").value(10000)); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("POST /payments/cancel ์ฑ๊ณต ์ 200 OK๋ฅผ ๋ฐํํ๋ค") | ||
| void cancel_success() throws Exception { | ||
| given(router.cancel(eq("toss"), any(CancelRequest.class))) | ||
| .willReturn(new CancelResponse(true, 10000, "tx_abc", null, null)); | ||
|
|
||
| mockMvc.perform(post("/api/v1/gateway/payments/cancel") | ||
| .param("provider", "toss") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"orderId": "order-1", "cancelAmount": 10000, "cancelReason": "๋จ์ ๋ณ์ฌ"} | ||
| """)) | ||
| .andExpect(status().isOk()) | ||
| .andExpect(jsonPath("$.isSuccess").value(true)) | ||
| .andExpect(jsonPath("$.cancelAmount").value(10000)) | ||
| .andExpect(jsonPath("$.transactionKey").value("tx_abc")); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("POST /payments/cancel ์คํจ ์์๋ 200 OK์ ์๋ฌ ์ ๋ณด๋ฅผ ๋ฐํํ๋ค") | ||
| void cancel_failure() throws Exception { | ||
| given(router.cancel(eq("toss"), any(CancelRequest.class))) | ||
| .willReturn(new CancelResponse(false, 0, null, "ALREADY_CANCELED", "์ด๋ฏธ ์ทจ์๋จ")); | ||
|
|
||
| mockMvc.perform(post("/api/v1/gateway/payments/cancel") | ||
| .param("provider", "toss") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"orderId": "order-1", "cancelAmount": 10000, "cancelReason": "ํ๋ถ"} | ||
| """)) | ||
| .andExpect(status().isOk()) | ||
| .andExpect(jsonPath("$.isSuccess").value(false)) | ||
| .andExpect(jsonPath("$.errorCode").value("ALREADY_CANCELED")); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("provider ํ๋ผ๋ฏธํฐ ์๋ต ์ ๊ธฐ๋ณธ๊ฐ toss๋ก ๋์ํ๋ค") | ||
| void cancel_defaultProvider() throws Exception { | ||
| given(router.cancel(eq("toss"), any(CancelRequest.class))) | ||
| .willReturn(new CancelResponse(true, 5000, "tx_def", null, null)); | ||
|
|
||
| mockMvc.perform(post("/api/v1/gateway/payments/cancel") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .content(""" | ||
| {"orderId": "order-2", "cancelAmount": 5000, "cancelReason": "ํ ์คํธ"} | ||
| """)) | ||
| .andExpect(status().isOk()) | ||
| .andExpect(jsonPath("$.isSuccess").value(true)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
cancelendpoint does not perform any authorization checks on theorderIdprovided in the request body. This allows any caller with access to the gateway service to cancel any payment that has been previously confirmed and stored in the instance's memory. If this service is exposed or accessible from other compromised internal services, it poses a high risk of unauthorized payment cancellations.