diff --git a/README.md b/README.md index 2e985da..870060f 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,46 @@ simplepg/ - **`payment` → `payment_log`**: 1:N 관계 (FK : `payment_id`, `ON DELETE CASCADE`) --- -## 테스트 방법 +## 🧪 테스트 방법 + +### 🌐 JSP 테스트 페이지 사용법 + +애플리케이션 실행 후 브라우저에서 다음 URL로 접속하여 테스트할 수 있습니다 + +#### 📊 메인 대시보드 +``` +http://localhost:8080/ +``` +- 시스템 상태 확인 +- 각 테스트 페이지로 이동하는 네비게이션 제공 + +#### 💳 결제 시스템 테스트 +``` +http://localhost:8080/payment-test +``` +- **결제 요청**: 새로운 결제 요청 생성 및 Payment Key 발급 +- **결제 상태 조회**: Payment Key로 결제 상태 확인 +- **결제 취소**: 결제 취소 처리 (사유 포함) +- **결제 완료**: 결제 완료 처리 +- **최근 결제 내역**: Mock 데이터로 결제 내역 확인 + +#### 🔄 웹훅 테스트 +``` +http://localhost:8080/webhook-test +``` +- **웹훅 플로우 시각화**: 결제 처리 과정을 단계별로 표시 +- **성공 웹훅 전송**: 외부 결제 시스템에서 성공 결과 전송 시뮬레이션 +- **실패 웹훅 전송**: 외부 결제 시스템에서 실패 결과 전송 시뮬레이션 +- **Mock 서버 요청**: Mock 서버로 결제 요청 전송 +- **실시간 로그**: 웹훅 전송 과정을 실시간으로 모니터링 + +#### 🔧 API 테스트 +``` +http://localhost:8080/api-test +``` +- **빠른 테스트**: 간단한 결제 요청 및 상태 조회 +- **JSON 요청 테스트**: 커스텀 JSON으로 API 직접 호출 +- **응답 분석**: API 응답 결과 분석 및 표시 ### 🔐 보안 고려사항 - 실제 설정 파일들은 `.gitignore`에 의해 제외됩니다 diff --git a/build.gradle b/build.gradle index 9f5168f..289f7bf 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ ext { junitVersion = '5.9.2' hikariVersion = '4.0.3' jstlVersion = '1.2' + retryVersion = '1.3.4' } dependencies { @@ -37,6 +38,10 @@ dependencies { implementation "org.springframework:spring-tx:${springVersion}" testImplementation "org.springframework:spring-test:${springVersion}" + // Spring retry + implementation "org.springframework.retry:spring-retry:${retryVersion}" + implementation "org.springframework:spring-aspects:${springVersion}" + // Spring Webflux implementation 'io.projectreactor:reactor-core:3.4.0' implementation 'org.springframework:spring-webflux:5.3.10' @@ -81,9 +86,10 @@ dependencies { implementation "commons-codec:commons-codec:1.15" // Servlet & JSP - implementation "javax.servlet:javax.servlet-api:3.1.0" + implementation "javax.servlet:javax.servlet-api:4.0.1" compileOnly "javax.servlet.jsp:jsp-api:2.2" implementation "javax.servlet:jstl:${jstlVersion}" + providedRuntime 'org.apache.tomcat.embed:tomcat-embed-jasper:9.0.75' // 보안 implementation("org.springframework.security:spring-security-web:${springSecurityVersion}") diff --git a/src/main/java/me/jaesung/simplepg/common/event/PaymentProcessedEvent.java b/src/main/java/me/jaesung/simplepg/common/event/PaymentProcessedEvent.java new file mode 100644 index 0000000..d5e6b20 --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/common/event/PaymentProcessedEvent.java @@ -0,0 +1,13 @@ +package me.jaesung.simplepg.common.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; + +@Getter +@AllArgsConstructor +public class PaymentProcessedEvent { + + private final MerchantRequest merchantRequest; + +} diff --git a/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java new file mode 100644 index 0000000..ba111e1 --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java @@ -0,0 +1,43 @@ +package me.jaesung.simplepg.common.event.handler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.jaesung.simplepg.common.event.PaymentProcessedEvent; +import me.jaesung.simplepg.service.webclient.WebClientService; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PaymentEventHandler { + + private final WebClientService webClientService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentEvent(PaymentProcessedEvent event) { + handlePaymentEventAsync(event); + } + + @Async + @Retryable(value = {Exception.class}, backoff = @Backoff(delay = 3000)) + public void handlePaymentEventAsync(PaymentProcessedEvent event) { + try { + log.info("Payment 정상 커밋 완료 후 비동기 요청 실행: {}", event.getMerchantRequest().getPaymentKey()); + webClientService.sendResponse(event.getMerchantRequest()); + } catch (Exception e) { + log.error("Payment -> Client 결제 정보 전송 실패 및 재시도 예정: {}", event.getMerchantRequest().getPaymentKey(), e); + throw e; + } + } + + @Recover + public void retryFailed(Exception e, PaymentProcessedEvent event) { + log.error("Payment -> Client 결제 정보 최종 실패: {}", event.getMerchantRequest().getPaymentKey(), e); + } +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/config/AppConfig.java b/src/main/java/me/jaesung/simplepg/config/AppConfig.java index b32de6e..fb1549f 100644 --- a/src/main/java/me/jaesung/simplepg/config/AppConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/AppConfig.java @@ -9,13 +9,16 @@ import org.mybatis.spring.SqlSessionFactoryBean; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.*; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; @@ -28,9 +31,10 @@ @PropertySource(value = "classpath:application-${spring.profiles.active}.properties", ignoreResourceNotFound = true) @MapperScan(basePackages = "me.jaesung.simplepg.mapper") -@ComponentScan(basePackages = "me.jaesung.simplepg.**") -@EnableTransactionManagement -public class AppConfig { +@ComponentScan(basePackages = "me.jaesung.simplepg.**", + excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Controller.class)) +@EnableTransactionManagement(mode = AdviceMode.PROXY, proxyTargetClass = true) +public class AppConfig implements AsyncConfigurer { @Value("${spring.datasource.driver-class-name}") String driver; @@ -66,12 +70,6 @@ public SqlSessionFactory sqlSessionFactory() throws Exception { return sqlSessionFactory.getObject(); } - @Bean - public DataSourceTransactionManager transactionManager() { - return new DataSourceTransactionManager(dataSource()); - } - - @Bean public ObjectMapper objectMapper() { return new ObjectMapper(); @@ -90,6 +88,14 @@ public WebClient webClient() { .build(); } + @Bean + public PlatformTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } + @Bean + public ApplicationEventPublisher applicationEventPublisher(ApplicationContext applicationContext) { + return applicationContext; + } } diff --git a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java new file mode 100644 index 0000000..7b9d6d4 --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java @@ -0,0 +1,34 @@ +package me.jaesung.simplepg.config; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +@EnableRetry +public class AsyncConfig implements AsyncConfigurer { + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-"); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new SimpleAsyncUncaughtExceptionHandler(); + } + +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/config/ServletConfig.java b/src/main/java/me/jaesung/simplepg/config/ServletConfig.java index ffabe64..29fa805 100644 --- a/src/main/java/me/jaesung/simplepg/config/ServletConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/ServletConfig.java @@ -1,13 +1,12 @@ package me.jaesung.simplepg.config; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.PropertySource; +import org.springframework.context.annotation.*; import org.springframework.web.multipart.MultipartResolver; import org.springframework.web.multipart.support.StandardServletMultipartResolver; import org.springframework.web.servlet.config.annotation.*; +import org.springframework.web.servlet.view.InternalResourceViewResolver; +import org.springframework.web.servlet.view.JstlView; @EnableWebMvc @Configuration @@ -15,10 +14,9 @@ @PropertySource(value = "classpath:application-${spring.profiles.active}.properties", ignoreResourceNotFound = true) @ComponentScan(basePackages = "me.jaesung.simplepg.controller") -@ComponentScan(basePackages = "me.jaesung.simplepg.service") public class ServletConfig implements WebMvcConfigurer { - @Value("${server.URI}") + @Value("${server.URI:http://localhost:8080}") String base_uri; @Override @@ -27,9 +25,10 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { .addResourceLocations("/resources/"); registry.addResourceHandler("/assets/**") .addResourceLocations("/resources/assets/"); + registry.addResourceHandler("/static/**") + .addResourceLocations("/static/"); } - @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") @@ -38,9 +37,21 @@ public void addCorsMappings(CorsRegistry registry) { .allowedHeaders("*"); } + /** + * JSP 뷰 리졸버 설정 + */ + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + InternalResourceViewResolver resolver = new InternalResourceViewResolver(); + resolver.setPrefix("/WEB-INF/jsp/"); + resolver.setSuffix(".jsp"); + resolver.setViewClass(JstlView.class); + resolver.setOrder(1); + registry.viewResolver(resolver); + } + @Bean public MultipartResolver multipartResolver() { return new StandardServletMultipartResolver(); } - } \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/config/WebConfig.java b/src/main/java/me/jaesung/simplepg/config/WebConfig.java index 3aaa7a4..39896bc 100644 --- a/src/main/java/me/jaesung/simplepg/config/WebConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/WebConfig.java @@ -1,13 +1,15 @@ package me.jaesung.simplepg.config; - +import org.springframework.web.filter.CharacterEncodingFilter; import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer; +import javax.servlet.Filter; public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer { + @Override protected Class[] getRootConfigClasses() { - return new Class[]{AppConfig.class, SecurityConfig.class}; + return new Class[]{AppConfig.class, SecurityConfig.class, AsyncConfig.class}; } @Override @@ -20,5 +22,14 @@ protected String[] getServletMappings() { return new String[]{"/"}; } - + /** + * Character Encoding Filter 설정 + */ + @Override + protected Filter[] getServletFilters() { + CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter(); + characterEncodingFilter.setEncoding("UTF-8"); + characterEncodingFilter.setForceEncoding(true); + return new Filter[]{characterEncodingFilter}; + } } diff --git a/src/main/java/me/jaesung/simplepg/controller/TestController.java b/src/main/java/me/jaesung/simplepg/controller/TestController.java new file mode 100644 index 0000000..8e365d9 --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/controller/TestController.java @@ -0,0 +1,73 @@ +package me.jaesung.simplepg.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@Slf4j +public class TestController { + + /** + * 메인 대시보드 페이지 + */ + @GetMapping("/") + public String index() { + log.info("메인 대시보드 페이지 요청"); + return "index"; + } + + /** + * 결제 시스템 테스트 페이지 + */ + @GetMapping("/payment-test") + public String paymentTest() { + log.info("결제 시스템 테스트 페이지 요청"); + return "payment-test"; + } + + /** + * 웹훅 테스트 페이지 + */ + @GetMapping("/webhook-test") + public String webhookTest() { + log.info("웹훅 테스트 페이지 요청"); + return "webhook-test"; + } + + /** + * API 테스트 페이지 + */ + @GetMapping("/api-test") + public String apiTest() { + log.info("API 테스트 페이지 요청"); + return "api-test"; + } + + /** + * 헬스 체크용 엔드포인트 + */ + @GetMapping("/health") + public String health() { + log.info("헬스 체크 요청"); + return "OK"; + } + + /** + * JSP 테스트용 엔드포인트 + */ + @GetMapping("/test") + public String test() { + log.info("JSP 테스트 페이지 요청"); + return "test"; + } + + /** + * 간단한 결제 테스트 페이지 + */ + @GetMapping("/payment-test-simple") + public String paymentTestSimple() { + log.info("간단한 결제 테스트 페이지 요청"); + return "payment-test-simple"; + } +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java index 3c3a79d..b3a8f50 100644 --- a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java +++ b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java @@ -3,8 +3,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.domain.dto.api.ApiCredentialResponse; -import me.jaesung.simplepg.domain.dto.webhook.ExternalApiDTO; +import me.jaesung.simplepg.domain.dto.webhook.CancelRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; +import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -22,16 +24,23 @@ public class MockController { private final WebClient webClient; + /** + * 외부 서버 역할을 하는 Controller입니다. + * + * @param webhookRequest + * @return + */ @PostMapping("/request") - public ResponseEntity handleMockData(@RequestBody ExternalApiDTO externalApiDTO) { + public ResponseEntity handleMockData(@RequestBody WebhookRequest webhookRequest) { + // transactionId는 랜덤으로 생성 String transactionId = UUID.randomUUID().toString(); - String paymentKey = externalApiDTO.getPaymentKey(); + String paymentKey = webhookRequest.getPaymentKey(); - WebhookRequest webhookRequest = WebhookRequest.builder() + WebhookResponse webhookResponse = WebhookResponse.builder() .transactionId(transactionId) .paymentStatus("APPROVED") - .approvedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) + .createdAt(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) .build(); new Thread(() -> { @@ -40,16 +49,16 @@ public ResponseEntity handleMockData(@RequestBody ExternalApiDTO externa webClient.post() .uri("http://localhost:8080/api/protected/webhook/" + paymentKey + "/success") - .bodyValue(webhookRequest) + .bodyValue(webhookResponse) .retrieve() .bodyToMono(String.class) .subscribe( - response -> log.info("웹훅 전송 성공: {}", response), - error -> log.error("웹훅 전송 실패: {}", error.getMessage()) + response -> log.info("결제 웹훅 전송 성공: {}", response), + error -> log.error("결제 웹훅 전송 실패: {}", error.getMessage()) ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.error("웹훅 전송 중 인터럽트 발생", e); + log.error("취소 웹훅 전송 중 인터럽트 발생", e); } }).start(); @@ -57,6 +66,32 @@ public ResponseEntity handleMockData(@RequestBody ExternalApiDTO externa return ResponseEntity.ok("Payment request received successfully. Transaction ID: " + transactionId); } + @PostMapping("/request/cancel") + public ResponseEntity handleCancelData(@RequestBody CancelRequest cancelRequest) { + + try { + Thread.sleep(3000); + + String transactionId = UUID.randomUUID().toString(); + + WebhookResponse webhookResponse = WebhookResponse.builder() + .transactionId(transactionId) + .paymentStatus(PaymentStatus.CANCELED.toString()) + .createdAt(LocalDateTime.now().toString()) + .build(); + + + log.info("Mock 서버가 취소 요청 접수 완료. 트랜잭션 ID: {}", transactionId); + return ResponseEntity.ok(webhookResponse); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("취소 처리 중 인터럽트 발생", e); + return ResponseEntity.internalServerError().build(); + } + + } + @PostMapping("/callback1") public ResponseEntity handleWebhookCallback( @RequestHeader("X-CLIENT-ID") String clientId, diff --git a/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java b/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java index 576648c..ca41f7a 100644 --- a/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java +++ b/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java @@ -1,6 +1,6 @@ package me.jaesung.simplepg.controller.webhook; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; +import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; import me.jaesung.simplepg.service.webhook.WebhookService; import me.jaesung.simplepg.service.webhook.WebhookServiceFactory; import org.springframework.web.bind.annotation.*; @@ -20,11 +20,11 @@ public WebhookController(WebhookServiceFactory webhookServiceFactory) { public void webhookProcess( @PathVariable String paymentKey, @PathVariable String webhookStatus, - @RequestBody WebhookRequest webhookRequest) { + @RequestBody WebhookResponse webhookResponse) { WebhookService webhookService = webhookServiceFactory.getWebhookService(webhookStatus); - webhookService.webhookProcess(webhookRequest, paymentKey); + webhookService.webhookProcess(webhookResponse, paymentKey); } diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/ExternalApiDTO.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/CancelRequest.java similarity index 74% rename from src/main/java/me/jaesung/simplepg/domain/dto/webhook/ExternalApiDTO.java rename to src/main/java/me/jaesung/simplepg/domain/dto/webhook/CancelRequest.java index 7dca5d2..910160c 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/ExternalApiDTO.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/CancelRequest.java @@ -9,16 +9,18 @@ * PG -> 외부 결제 */ @Data -@Builder @AllArgsConstructor @NoArgsConstructor -public class ExternalApiDTO { +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CancelRequest { private String paymentKey; private String amount; private String orderNo; private String customerName; private String methodCode; - private String successUrl; - private String failureUrl; + private String returnUrl; private String transactionId; private String cancelReason; + } diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java new file mode 100644 index 0000000..98aa4bb --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java @@ -0,0 +1,20 @@ +package me.jaesung.simplepg.domain.dto.webhook; + +import lombok.Builder; +import lombok.Data; + +/** + * PG -> 가맹점 + */ +@Data +@Builder +public class MerchantRequest { + + private String clientId; + private String paymentKey; + private String amount; + private String orderNo; + private String customerName; + private String methodCode; + +} diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java index 325f35c..2cae1ab 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java @@ -5,10 +5,21 @@ import lombok.Data; import lombok.NoArgsConstructor; -@Data @Builder -@AllArgsConstructor @NoArgsConstructor +/** + * PG -> 외부 결제 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor public class WebhookRequest { + + private String paymentKey; + private String amount; + private String orderNo; + private String customerName; + private String methodCode; + private String returnUrl; private String transactionId; - private String paymentStatus; - private String approvedAt; + } diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java index 7dd026f..ff1ba63 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java @@ -1,18 +1,14 @@ package me.jaesung.simplepg.domain.dto.webhook; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; -/** - * PG -> 가맹점 - */ -@Data -@Builder +@Data @Builder +@AllArgsConstructor @NoArgsConstructor public class WebhookResponse { - private String clientId; - private String paymentKey; - private String amount; - private String orderNo; - private String customerName; - private String methodCode; + private String transactionId; + private String paymentStatus; + private String createdAt; } diff --git a/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java b/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java index 81444f6..ff7f38a 100644 --- a/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java +++ b/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java @@ -4,6 +4,9 @@ import java.time.LocalDateTime; +/** + * 상점 <-> SimplePG 통신에서 필요한 상점의 식별자(clientId), MAC 인증에 필요한 secret_key(clientSecret) + */ @Getter public class ApiCredential { private String clientId; diff --git a/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java b/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java index 2415e60..509e9c0 100644 --- a/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java +++ b/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java @@ -173,6 +173,12 @@ private void validatePaymentStatusTransition(PaymentStatus currentStatus, Paymen } } + /** + * 결제 생성 메서드 + * @param request + * @param amountBD + * @return + */ private PaymentDTO createPayment(PaymentRequest request, BigDecimal amountBD) { String paymentId = String.valueOf(UUID.randomUUID()); String orderNo = request.getOrderNo(); diff --git a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java index 69279d7..ed12519 100644 --- a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java +++ b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java @@ -6,15 +6,18 @@ import me.jaesung.simplepg.common.util.HmacUtil; import me.jaesung.simplepg.domain.dto.api.ApiCredentialResponse; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; -import me.jaesung.simplepg.domain.dto.webhook.ExternalApiDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.dto.webhook.CancelRequest; +import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; +import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; import me.jaesung.simplepg.domain.vo.api.ApiCredential; import me.jaesung.simplepg.mapper.ApiCredentialMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import java.time.Duration; import java.time.LocalDateTime; @Service @@ -29,7 +32,7 @@ public class WebClientService { public WebClientService(@Value("${api.request.url}") String requestApiUrl, @Value("${api.return.url}") String returnUrl, - @Value("${api.request.url}/cancel}") String cancelUrl, + @Value("${api.request.url}/cancel") String cancelUrl, WebClient webClient, ApiCredentialMapper apiCredentialMapper) { this.requestApiUrl = requestApiUrl; this.returnUrl = returnUrl; @@ -40,60 +43,59 @@ public WebClientService(@Value("${api.request.url}") String requestApiUrl, /** * 외부 결제 시스템으로 결제 요청 + * 비동기 + 웹훅 (Mono) + * * @param paymentDTO */ public void sendRequest(PaymentDTO paymentDTO) { - ExternalApiDTO externalApiDTO = ExternalApiDTO.builder() + WebhookRequest webhookRequest = WebhookRequest.builder() .paymentKey(paymentDTO.getPaymentKey()) .amount(paymentDTO.getAmount().toString()) .orderNo(paymentDTO.getOrderNo()) .customerName(paymentDTO.getCustomerName()) .methodCode(paymentDTO.getMethodCode().toString()) - .successUrl(returnUrl + "/" + paymentDTO.getPaymentKey() + "/success") - .failureUrl(returnUrl + "/" + paymentDTO.getPaymentKey() + "/failure") + .returnUrl(returnUrl) .build(); webClient.post() .uri(requestApiUrl) - .bodyValue(externalApiDTO) + .bodyValue(webhookRequest) .retrieve() .bodyToMono(String.class) - .subscribe( - null, - error -> { - throw new PaymentException.ExternalPaymentException("외부 결제 서버로 요청 실패"); - } - ); + .doOnSuccess(response -> log.info("PG -> 외부 결제 시스템 요청 성공: {}", response)) + .doOnError(error -> log.error("PG -> 외부 결제 시스템 요청 실패: {}", error)) + .onErrorResume(error -> Mono.empty()) + .subscribe(); } - + /** * 외부 결제 시스템으로 결제 취소 요청 전송 - * @param paymentDTO 취소할 결제 정보 + * @param paymentDTO 취소할 결제 정보 * @param cancelReason 취소 사유(옵션) * @throws PaymentException.ExternalPaymentException 외부 API 호출 실패 시 */ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { try { - log.info("결제 취소 요청 전송: paymentKey={}, transactionId={}", + log.info("결제 취소 요청 전송: paymentKey={}, transactionId={}", paymentDTO.getPaymentKey(), paymentDTO.getTransactionId()); - - ExternalApiDTO cancelRequest = ExternalApiDTO.builder() + + CancelRequest cancelRequest = CancelRequest.builder() .paymentKey(paymentDTO.getPaymentKey()) .transactionId(paymentDTO.getTransactionId()) .amount(paymentDTO.getAmount().toString()) .orderNo(paymentDTO.getOrderNo()) .cancelReason(cancelReason != null ? cancelReason : "고객 요청에 의한 취소") .build(); - + // 동기 방식으로 요청 처리 - 취소는 즉시 응답이 필요함 String response = webClient.post() .uri(cancelUrl) .bodyValue(cancelRequest) .retrieve() .bodyToMono(String.class) - .block(); - + .block(Duration.ofSeconds(30)); + log.debug("결제 취소 응답: {}", response); } catch (Exception e) { log.error("결제 취소 요청 실패: {}", e.getMessage(), e); @@ -101,49 +103,56 @@ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { } } + /** * 가맹점 서버에 웹훅 응답 전송 - * @param webhookResponse - * @throws Exception + * + * @param merchantRequest */ - public void sendResponse(WebhookResponse webhookResponse) throws Exception { - - String data = plusBody(webhookResponse); - String clientId = webhookResponse.getClientId(); - - ApiCredential apiCredential = apiCredentialMapper.findByClientId(clientId) - .orElseThrow(() -> new ApiException.NotFoundException("해당 clientId가 존재하지 않습니다")); - String clientSecret = apiCredential.getClientSecret(); - - ApiCredentialResponse apiCredentialResponse = ApiCredentialResponse.of(apiCredential); - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.add("X-CLIENT-ID", clientId); - httpHeaders.add("X-TIMESTAMP", LocalDateTime.now().toString()); - httpHeaders.add("X-SIGNATURE", HmacUtil.generateSignature(data,clientSecret)); - - webClient.post() - .uri(apiCredential.getReturnUrl()) - .headers(h -> h.addAll(httpHeaders)) - .bodyValue(apiCredentialResponse) - .retrieve() - .bodyToMono(ApiCredentialResponse.class) - .block(); + public void sendResponse(MerchantRequest merchantRequest) { + try { + String data = plusBody(merchantRequest); + String clientId = merchantRequest.getClientId(); + + ApiCredential apiCredential = apiCredentialMapper.findByClientId(clientId) + .orElseThrow(() -> new ApiException.NotFoundException("해당 clientId가 존재하지 않습니다")); + String clientSecret = apiCredential.getClientSecret(); + + ApiCredentialResponse apiCredentialResponse = ApiCredentialResponse.of(apiCredential); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add("X-CLIENT-ID", clientId); + httpHeaders.add("X-TIMESTAMP", LocalDateTime.now().toString()); + httpHeaders.add("X-SIGNATURE", HmacUtil.generateSignature(data, clientSecret)); + + webClient.post() + .uri(apiCredential.getReturnUrl()) + .headers(h -> h.addAll(httpHeaders)) + .bodyValue(apiCredentialResponse) + .retrieve() + .bodyToMono(ApiCredentialResponse.class) + .doOnSuccess(response -> log.info("가맹점 서버 응답 성공: {}", response)) + .doOnError(error -> log.error("가맹점 서버 요청 실패: {}", error.getMessage(), error)) + .onErrorResume(error -> Mono.empty()) + .subscribe(); + } catch (Exception e) { + log.error("가맹점 서버로의 요청 준비 실패: {}", e.getMessage(), e); + } } - private String plusBody(WebhookResponse webhookResponse) { + private String plusBody(MerchantRequest merchantRequest) { try { - if (webhookResponse == null) { + if (merchantRequest == null) { throw new NullPointerException("webhookResponse가 null입니다"); } StringBuffer sb = new StringBuffer(); - sb.append(webhookResponse.getClientId()); - sb.append(webhookResponse.getPaymentKey()); - sb.append(webhookResponse.getAmount()); - sb.append(webhookResponse.getOrderNo()); - sb.append(webhookResponse.getCustomerName()); - sb.append(webhookResponse.getMethodCode()); + sb.append(merchantRequest.getClientId()); + sb.append(merchantRequest.getPaymentKey()); + sb.append(merchantRequest.getAmount()); + sb.append(merchantRequest.getOrderNo()); + sb.append(merchantRequest.getCustomerName()); + sb.append(merchantRequest.getMethodCode()); return sb.toString(); @@ -152,4 +161,4 @@ private String plusBody(WebhookResponse webhookResponse) { throw new PaymentException.ProcessingException("webhook 수신 후 바디 생성 중 예외 발생"); } } -} +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java index 4097bfd..335dd1f 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java @@ -1,40 +1,37 @@ package me.jaesung.simplepg.service.webhook; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.common.exception.PaymentException; -import me.jaesung.simplepg.common.util.DateTimeUtil; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; import me.jaesung.simplepg.domain.dto.payment.PaymentLogDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; import me.jaesung.simplepg.domain.vo.payment.PaymentLogAction; import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; -import me.jaesung.simplepg.service.webclient.WebClientService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @Service -@Slf4j +@Slf4j @Transactional public class WebhookFailedService extends WebhookServiceImpl { - public WebhookFailedService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, WebClientService webClientService) { - super(paymentMapper, paymentLogMapper, webClientService); + public WebhookFailedService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { + super(paymentMapper, paymentLogMapper, eventPublisher); } @Override - protected void validatePayment(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { if (!webhookRequest.getPaymentStatus().equals(PaymentStatus.FAILED.toString())) { throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); } } @Override - protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { paymentDTO.setStatus(PaymentStatus.FAILED); paymentDTO.setTransactionId(webhookRequest.getTransactionId()); diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java index 0b4b293..08e4caa 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java @@ -1,8 +1,7 @@ package me.jaesung.simplepg.service.webhook; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; public interface WebhookService { - void webhookProcess(WebhookRequest webhookRequest, String paymentKey); + void webhookProcess(WebhookResponse webhookRequest, String paymentKey); } diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java index d96283f..839983c 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java @@ -2,66 +2,73 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import me.jaesung.simplepg.common.event.PaymentProcessedEvent; import me.jaesung.simplepg.common.exception.PaymentException; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; -import me.jaesung.simplepg.service.webclient.WebClientService; -import org.springframework.stereotype.Service; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.annotation.Transactional; -@Service @Slf4j @RequiredArgsConstructor public abstract class WebhookServiceImpl implements WebhookService { protected final PaymentMapper paymentMapper; protected final PaymentLogMapper paymentLogMapper; - protected final WebClientService webClientService; + private final ApplicationEventPublisher eventPublisher; - @Override + /** + * 1.상점 서버로 보낼 결제 및 로그 상태 처리 및 저장 + * 2.가맹점 서버로 보낼 DTO 와 함께 이벤트 발생 (비동기 요청) + * + * @param webhookResponse (결제 서버로 부터 웹훅으로 받은 데이터) + * @param paymentKey (결제 고유 키 -> URL 파라미터로 받음) + */ @Transactional - public void webhookProcess(WebhookRequest webhookRequest, String paymentKey) { - try { - - PaymentDTO paymentDTO = paymentMapper.findByPaymentKeyWithLock(paymentKey) - .orElseThrow(() -> new PaymentException.InvalidPaymentRequestException("결제 정보를 찾을 수 없습니다")); - - if (paymentDTO.getStatus() != PaymentStatus.READY) { - throw new PaymentException.InvalidPaymentRequestException("이미 처리된 결제 내역 입니다"); - } - - validatePayment(paymentDTO, webhookRequest); + @Override + public void webhookProcess(WebhookResponse webhookResponse, String paymentKey) { + MerchantRequest merchantRequest = paymentTransaction(webhookResponse, paymentKey); + eventPublisher.publishEvent(new PaymentProcessedEvent(merchantRequest)); + } - processPaymentStatus(paymentDTO, webhookRequest); + /** + * 상점 서버로 보낼 결제 및 로그 상태 처리 및 저장 + * @param webhookResponse (결제 서버로 부터 웹훅으로 받은 데이터) + * @param paymentKey (결제 고유 키 -> URL 파라미터로 받음) + * @return MerchantRequest (상점 서버로 보낼 데이터 ) + */ + private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, String paymentKey) { + PaymentDTO paymentDTO = paymentMapper.findByPaymentKeyWithLock(paymentKey) + .orElseThrow(() -> new PaymentException.InvalidPaymentRequestException("결제 정보를 찾을 수 없습니다")); - WebhookResponse webhookResponse = WebhookResponse.builder() - .clientId(paymentDTO.getClientId()) - .paymentKey(paymentKey) - .amount(paymentDTO.getAmount().toString()) - .orderNo(paymentDTO.getOrderNo()) - .customerName(paymentDTO.getCustomerName()) - .methodCode(paymentDTO.getMethodCode().toString()) - .build(); + if (paymentDTO.getStatus() != PaymentStatus.READY) { + throw new PaymentException.InvalidPaymentRequestException("이미 처리된 결제 내역 입니다"); + } - webClientService.sendResponse(webhookResponse); + processPaymentStatus(paymentDTO, webhookResponse); + validatePayment(paymentDTO, webhookResponse); - } catch (Exception e) { - log.error("웹훅 처리 실패: paymentKey={}, 원인={}", paymentKey, e.getMessage(), e); - throw new PaymentException.WebhookProcessingException("웹훅 수신 데이터 처리 실패"); - } + return MerchantRequest.builder() + .clientId(paymentDTO.getClientId()) + .paymentKey(paymentKey) + .amount(paymentDTO.getAmount().toString()) + .orderNo(paymentDTO.getOrderNo()) + .customerName(paymentDTO.getCustomerName()) + .methodCode(paymentDTO.getMethodCode().toString()) + .build(); } /** - * 결제 정보와 웹훅 요청 검증 + * 결제 정보와 웹훅 요청 검증 (Success/Failed에 따라 분기) */ - protected abstract void validatePayment(PaymentDTO paymentDTO, WebhookRequest webhookRequest); + protected abstract void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookResponse); /** - * 결제 상태 처리 + * 결제 상태 처리 (Success/Failed에 따라 분기) */ - protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookRequest webhookRequest); -} + protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse); +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java index 63ccc44..e2d4c8e 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java @@ -1,18 +1,15 @@ package me.jaesung.simplepg.service.webhook; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.common.exception.PaymentException; -import me.jaesung.simplepg.common.util.DateTimeUtil; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; import me.jaesung.simplepg.domain.dto.payment.PaymentLogDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; import me.jaesung.simplepg.domain.vo.payment.PaymentLogAction; import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; -import me.jaesung.simplepg.service.webclient.WebClientService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,25 +17,25 @@ @Service -@Slf4j +@Slf4j @Transactional public class WebhookSuccessService extends WebhookServiceImpl { - public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, WebClientService webClientService) { - super(paymentMapper, paymentLogMapper, webClientService); + public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { + super(paymentMapper, paymentLogMapper, eventPublisher); } @Override - protected void validatePayment(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { - if (!webhookRequest.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { + if (!webhookResponse.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); } } @Override - protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { paymentDTO.setStatus(PaymentStatus.APPROVED); - paymentDTO.setApprovedAt(LocalDateTime.parse(webhookRequest.getApprovedAt())); - paymentDTO.setTransactionId(webhookRequest.getTransactionId()); + paymentDTO.setApprovedAt(LocalDateTime.parse(webhookResponse.getCreatedAt())); + paymentDTO.setTransactionId(webhookResponse.getTransactionId()); PaymentLogDTO paymentLogDTO = PaymentLogDTO.builder() .paymentId(paymentDTO.getPaymentId()) diff --git a/src/main/resources/mybatis-config.xml b/src/main/resources/mybatis-config.xml index 966fb6f..126474a 100644 --- a/src/main/resources/mybatis-config.xml +++ b/src/main/resources/mybatis-config.xml @@ -6,7 +6,7 @@ - + diff --git a/src/main/webapp/WEB-INF/jsp/api-test.jsp b/src/main/webapp/WEB-INF/jsp/api-test.jsp new file mode 100644 index 0000000..96eef48 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/api-test.jsp @@ -0,0 +1,299 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + + SimplePG - API 테스트 + + + +
+
+

SimplePG API 테스트

+

REST API 엔드포인트를 직접 테스트할 수 있습니다

+
+ +
+
+

빠른 테스트

+ +
+
+

결제 요청

+
+ + +
+
+ + +
+ +
+ +
+

결제 상태 조회

+
+ + +
+ +
+
+ + +
+ +
+

JSON 요청 테스트

+ +
+ + +
+ + + + + +
+
+
+ + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/index.jsp b/src/main/webapp/WEB-INF/jsp/index.jsp new file mode 100644 index 0000000..6f36d16 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/index.jsp @@ -0,0 +1,312 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + SimplePG - 결제 시스템 테스트 대시보드 + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/payment-test-simple.jsp b/src/main/webapp/WEB-INF/jsp/payment-test-simple.jsp new file mode 100644 index 0000000..ba56359 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/payment-test-simple.jsp @@ -0,0 +1,146 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> + + + + + SimplePG - 결제 시스템 테스트 (간단 버전) + + + +
+

💳 SimplePG 결제 시스템 테스트

+

간단한 결제 요청 테스트

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/payment-test.jsp b/src/main/webapp/WEB-INF/jsp/payment-test.jsp new file mode 100644 index 0000000..820d137 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/payment-test.jsp @@ -0,0 +1,544 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + + SimplePG - 결제 시스템 테스트 + + + +
+
+

💳 SimplePG 결제 시스템

+

결제 요청, 상태 조회, 취소, 완료 기능 테스트

+
+ +
+ +
+

🔵 1. 결제 요청 테스트

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+

🔍 2. 결제 상태 조회 테스트

+
+ + +
+ + +
+ + +
+

❌ 3. 결제 취소 테스트

+
+ + +
+
+ + +
+ + +
+ + +
+

✅ 4. 결제 완료 테스트

+
+ + +
+ + +
+ + +
+

📋 5. 최근 결제 내역

+ + +
+
+
+ + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/test.jsp b/src/main/webapp/WEB-INF/jsp/test.jsp new file mode 100644 index 0000000..3a487f3 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/test.jsp @@ -0,0 +1,21 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + JSP 테스트 + + +

JSP 테스트 페이지

+

현재 시간: <%= new java.util.Date() %>

+

JSP 뷰 리졸버가 정상적으로 작동하고 있습니다!

+ +

다른 페이지로 이동:

+ + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/webhook-test.jsp b/src/main/webapp/WEB-INF/jsp/webhook-test.jsp new file mode 100644 index 0000000..5cefb7c --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/webhook-test.jsp @@ -0,0 +1,517 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + SimplePG - 웹훅 테스트 + + + +
+
+

🔄 SimplePG 웹훅 테스트

+

외부 결제 시스템 → SimplePG 웹훅 시뮬레이션

+
+ +
+ +
+

📋 웹훅 처리 플로우

+
+
1
+
+
결제 요청 생성
+
SimplePG에서 결제 요청을 생성하고 Payment Key 발급
+
+
+
+
2
+
+
외부 결제 시스템으로 요청 전송
+
WebClient를 통해 외부 결제 시스템으로 비동기 요청
+
+
+
+
3
+
+
외부 시스템에서 웹훅 응답
+
결제 처리 완료 후 SimplePG로 웹훅 전송
+
+
+
+
4
+
+
SimplePG에서 웹훅 처리
+
결제 상태 업데이트 및 가맹점 서버로 결과 전송
+
+
+
+ + +
+

🎯 웹훅 시뮬레이션

+

외부 결제 시스템에서 SimplePG로 웹훅을 보내는 상황을 시뮬레이션합니다.

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + +
+ + +
+

📊 웹훅 로그

+ +
+ 웹훅 로그가 여기에 표시됩니다... +
+
+ + +
+

🧪 테스트 시나리오

+
+
+

시나리오 1: 정상 결제 플로우

+
    +
  1. 결제 요청 생성 (Payment Key 발급)
  2. +
  3. 성공 웹훅 전송
  4. +
  5. 결제 상태 확인 (APPROVED)
  6. +
  7. 결제 완료 처리
  8. +
+
+
+

시나리오 2: 결제 실패 플로우

+
    +
  1. 결제 요청 생성 (Payment Key 발급)
  2. +
  3. 실패 웹훅 전송
  4. +
  5. 결제 상태 확인 (FAILED)
  6. +
  7. 재시도 또는 취소
  8. +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp deleted file mode 100644 index dd88878..0000000 --- a/src/main/webapp/index.jsp +++ /dev/null @@ -1,13 +0,0 @@ -<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> - - - - JSP - Hello World - - -

<%= "Hello World!" %> -

-
-Hello Servlet - - \ No newline at end of file diff --git a/src/test/java/me/jaesung/simplepg/service/webhook/WebhookServiceImplTest.java b/src/test/java/me/jaesung/simplepg/service/webhook/WebhookServiceImplTest.java new file mode 100644 index 0000000..db90aa7 --- /dev/null +++ b/src/test/java/me/jaesung/simplepg/service/webhook/WebhookServiceImplTest.java @@ -0,0 +1,113 @@ +package me.jaesung.simplepg.service.webhook; + +import me.jaesung.simplepg.common.exception.PaymentException; +import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; +import me.jaesung.simplepg.domain.dto.payment.PaymentLogDTO; +import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.vo.payment.*; +import me.jaesung.simplepg.mapper.PaymentLogMapper; +import me.jaesung.simplepg.mapper.PaymentMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WebhookServiceImplTest { + + @Mock + private PaymentMapper paymentMapper; + + @Mock + private PaymentLogMapper paymentLogMapper; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private TestWebhookServiceImpl testWebhookService; + + @Test + @DisplayName("이벤트 발행 테스트") + void webhookProcess() { + //given + String paymentKey = "test-payment-key"; + PaymentDTO mockPaymentDTO = createMockPaymentDTO(paymentKey); + WebhookResponse mockWebhookResponse = createMockWebhookResponse(); + + when(paymentMapper.findByPaymentKeyWithLock(paymentKey)) + .thenReturn(Optional.ofNullable(mockPaymentDTO)); + + //when + testWebhookService.webhookProcess(mockWebhookResponse, paymentKey); + + ///then + + + } + + + private static class TestWebhookServiceImpl extends WebhookServiceImpl { + + public TestWebhookServiceImpl(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { + super(paymentMapper, paymentLogMapper, eventPublisher); + } + + @Override + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { + if (!webhookResponse.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { + throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); + } + } + + @Override + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { + paymentDTO.setStatus(PaymentStatus.APPROVED); + paymentDTO.setApprovedAt(LocalDateTime.parse(webhookResponse.getApprovedAt())); + paymentDTO.setTransactionId(webhookResponse.getTransactionId()); + + PaymentLogDTO paymentLogDTO = PaymentLogDTO.builder() + .paymentId(paymentDTO.getPaymentId()) + .action(PaymentLogAction.APPROVE) + .status(PaymentStatus.APPROVED) + .details("승인된 결제 내역입니다(승인 일시:" + LocalDateTime.now() + ")") + .build(); + + paymentMapper.updatePayment(paymentDTO); + paymentLogMapper.insertPaymentLog(paymentLogDTO); + } + } + + private PaymentDTO createMockPaymentDTO(String paymentKey) { + return PaymentDTO.builder() + .paymentKey(paymentKey) + .clientId("test-client") + .orderNo("test-order") + .transactionId("test-TID") + .amount(BigDecimal.valueOf(10000)) + .customerName("testUser") + .status(PaymentStatus.READY) + .productName("test-product") + .methodCode(MethodCode.BANK) + .createdAt(LocalDateTime.now()) + .build(); + + } + + private WebhookResponse createMockWebhookResponse() { + return WebhookResponse.builder() + .paymentStatus(String.valueOf(PaymentStatus.APPROVED)) + .transactionId("test-TID") + .approvedAt(LocalDateTime.now().toString()) + .build(); + } +} \ No newline at end of file