From 0ab08acf57bc616685369147144f580ae3184905 Mon Sep 17 00:00:00 2001 From: Kang Dong Hyeon Date: Tue, 24 Jun 2025 18:46:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=20API=20=EB=B0=8F=20=ED=8F=AC=ED=8A=B8=EC=9B=90=20V2?= =?UTF-8?q?=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트엔드에서 결제창을 호출하기 전에 필요한 정보(주문번호, 결제 금액)를 생성하여 제공하는 '결제 준비 API'를 구현합니다. 주요 변경사항: - **PaymentController:** - `POST /api/payments/request` 엔드포인트를 추가하여 결제 준비 요청을 처리합니다. - **PaymentService:** - 사용자의 구독 요청(tier)에 따라 `Order`를 `PENDING` 상태로 생성하고, `merchant_uid`와 결제 금액을 포함한 `PaymentPreparationResponse`를 반환하는 로직을 구현합니다. - **PortOne V2 Client:** - 최신 포트원 V2 API와 연동하기 위해 `WebClient` 기반의 클라이언트를 구성합니다. - `@ConfigurationProperties`를 사용하여 API Secret Key, Base URL 등의 설정을 외부화하고, `application-local.yml`에 로컬 환경 설정을 추가합니다. - `AbstractPortOneClient`에 API Secret을 사용하여 Access Token과 Refresh Token을 발급받고, 만료 시 자동으로 갱신하는 토큰 관리 로직을 구현합니다. - **보안 및 인증:** - API Gateway로부터 전달받은 사용자 ID(`X-Authenticated-Member-Id`)를 `MemberAuthenticationFilter`에서 파싱하여 `SecurityContext`에 저장, 이후 서비스 로직에서 `@AuthenticationPrincipal`로 주입받아 사용하도록 구성합니다. 마이크로 서비스 패턴인 신원 정보 전파를 활용하여 인증의 책임을 api-gateway에서 처리하고 전파 받도록 하였습니다. --- payment-service/build.gradle | 2 + .../config/PortOneClientConfig.java | 25 +++++ .../config/PortOneClientProperties.java | 11 ++ .../config/ResourceServerConfig.java | 12 +- .../controller/PaymentController.java | 38 +++++++ .../synapse/payment_service/domain/Order.java | 9 +- .../domain/enums/PaymentStatus.java | 3 +- .../payment_service/dto/IamportResponse.java | 14 +++ .../payment_service/dto/PaymentData.java | 31 ++++++ .../dto/PortOneAuthResponse.java | 8 ++ .../dto/PortOneErrorResponse.java | 14 +++ .../dto/request/PaymentRequestDto.java | 10 ++ .../response/PaymentPreparationResponse.java | 11 ++ .../exception/ExceptionCode.java | 21 ++++ .../exception/NotFoundException.java | 7 ++ .../exception/PaymentException.java | 13 +++ .../exception/PortOneClientException.java | 7 ++ .../filter/MemberAuthenticationFilter.java | 51 +++++++++ .../infrastructure/AbstractPortOneClient.java | 105 ++++++++++++++++++ .../infrastructure/PortOneClient.java | 30 +++++ .../repository/SubscriptionRepository.java | 5 +- .../service/PaymentService.java | 53 +++++++++ .../src/main/resources/application-local.yml | 4 + .../src/main/resources/application.yml | 1 + 24 files changed, 480 insertions(+), 5 deletions(-) create mode 100644 payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentRequestDto.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/exception/NotFoundException.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/exception/PaymentException.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/filter/MemberAuthenticationFilter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java diff --git a/payment-service/build.gradle b/payment-service/build.gradle index d5f0cdf..28c9ed5 100644 --- a/payment-service/build.gradle +++ b/payment-service/build.gradle @@ -31,6 +31,8 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' // OAuth2 Resource Server implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java new file mode 100644 index 0000000..46a77fe --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java @@ -0,0 +1,25 @@ +package com.synapse.payment_service.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableConfigurationProperties(PortOneClientProperties.class) +@RequiredArgsConstructor +public class PortOneClientConfig { + private final PortOneClientProperties properties; + + @Bean + public WebClient portOneWebClient() { + return WebClient.builder() + .baseUrl(properties.baseUrl()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java new file mode 100644 index 0000000..e3da0d7 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java @@ -0,0 +1,11 @@ +package com.synapse.payment_service.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "iamport") +public record PortOneClientProperties( + String apiSecret, + String baseUrl +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java index 2534da3..914c2a7 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java @@ -11,14 +11,23 @@ import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.synapse.payment_service.filter.MemberAuthenticationFilter; + +import lombok.RequiredArgsConstructor; + import org.springframework.security.authorization.AuthorizationManagers; import org.springframework.security.authorization.AuthorityAuthorizationManager; @Configuration(proxyBeanMethods = false) @EnableMethodSecurity @EnableWebSecurity +@RequiredArgsConstructor public class ResourceServerConfig { - + + private final MemberAuthenticationFilter memberAuthenticationFilter; + @Bean public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) throws Exception { http @@ -34,6 +43,7 @@ public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) )) ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())) + .addFilterBefore(memberAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(exceptions -> exceptions .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) diff --git a/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java b/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java new file mode 100644 index 0000000..d6dbb44 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java @@ -0,0 +1,38 @@ +package com.synapse.payment_service.controller; + +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.synapse.payment_service.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +import com.synapse.payment_service.service.PaymentService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/payments") +@RequiredArgsConstructor +public class PaymentController { + private final PaymentService paymentService; + + /** + * 프론트엔드에서 결제창을 띄우기 전에, + * 결제에 필요한 정보(주문번호, 금액 등)를 생성하고 반환합니다. + */ + @PostMapping("/request") + public ResponseEntity requestPayment( + @RequestBody @Valid PaymentRequestDto request, + @AuthenticationPrincipal UUID memberId + ) { + PaymentPreparationResponse response = paymentService.preparePayment(memberId, request); + return ResponseEntity.ok().body(response); + } + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java index cb58dfe..aec789f 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java @@ -1,5 +1,6 @@ package com.synapse.payment_service.domain; +import java.math.BigDecimal; import java.time.ZonedDateTime; import com.synapse.payment_service.common.BaseEntity; @@ -24,12 +25,15 @@ public class Order extends BaseEntity { @JoinColumn(name = "subscription_id", nullable = false) private Subscription subscription; - @Column(nullable = false, unique = true) + @Column(unique = true) private String iamportUid; // 아임포트에서 사용하는 결제 건별 고유 ID, 환불시 사용 @Column(nullable = false, unique = true) private String merchantUid; // 주문별 고유 ID. 중복 결제 방지 + @Column(nullable = false) + private BigDecimal amount; + @Enumerated(EnumType.STRING) @Column(nullable = false) private PaymentStatus status; @@ -37,10 +41,11 @@ public class Order extends BaseEntity { private ZonedDateTime paidAt; @Builder - public Order(Subscription subscription, String iamportUid, String merchantUid, PaymentStatus status, ZonedDateTime paidAt) { + public Order(Subscription subscription, String iamportUid, String merchantUid, BigDecimal amount, PaymentStatus status, ZonedDateTime paidAt) { this.subscription = subscription; this.iamportUid = iamportUid; this.merchantUid = merchantUid; + this.amount = amount; this.status = status; this.paidAt = paidAt; } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java index bcf3c98..f19866e 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java @@ -3,5 +3,6 @@ public enum PaymentStatus { PAID, // 결제 완료 FAILED, // 결제 실패 - CANCELLED // 결제 취소 (환불) + CANCELLED, // 결제 취소 (환불) + PENDING // 결제 대기 } diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java new file mode 100644 index 0000000..192fdee --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java @@ -0,0 +1,14 @@ +package com.synapse.payment_service.dto; + +/** + * 아임포트 API의 공통적인 응답 래퍼(Wrapper) 클래스입니다. + * + * @param 실제 데이터의 타입 (예: PaymentData) + */ +public record IamportResponse( + int code, + String message, + T response +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java b/payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java new file mode 100644 index 0000000..18f05f7 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java @@ -0,0 +1,31 @@ +package com.synapse.payment_service.dto; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record PaymentData( + BigDecimal amount, + + // 결제 성공/실패/취소 여부를 판단하기 위한 상태 + String status, + + // 우리 시스템의 주문 정보와 매칭하기 위한 주문번호 + @JsonProperty("merchant_uid") String merchantUid, + + // 실패 시 원인 파악을 위한 필드 + @JsonProperty("fail_reason") String failReason, + + // 고객에게 보여줄 결제 수단 정보 + @JsonProperty("card_name") String cardName, + + @JsonProperty("card_number") String cardNumber, + + //결제 완료 시각 + ZonedDateTime paidAt +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java new file mode 100644 index 0000000..584bd03 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java @@ -0,0 +1,8 @@ +package com.synapse.payment_service.dto; + +public record PortOneAuthResponse( + String accessToken, + String refreshToken +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java new file mode 100644 index 0000000..915c952 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java @@ -0,0 +1,14 @@ +package com.synapse.payment_service.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record PortOneErrorResponse( + ErrorDetails error +) { + public record ErrorDetails( + String code, String message + ) { + + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentRequestDto.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentRequestDto.java new file mode 100644 index 0000000..8bbf75d --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentRequestDto.java @@ -0,0 +1,10 @@ +package com.synapse.payment_service.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentRequestDto( + @NotBlank(message = "구독 티어는 필수입니다") + String tier +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java new file mode 100644 index 0000000..ec23dd6 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java @@ -0,0 +1,11 @@ +package com.synapse.payment_service.dto.response; + +import java.math.BigDecimal; + +public record PaymentPreparationResponse( + String merchantUid, + String orderName, + BigDecimal amount +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java new file mode 100644 index 0000000..8c733d0 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java @@ -0,0 +1,21 @@ +package com.synapse.payment_service.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Getter +@RequiredArgsConstructor +public enum ExceptionCode { + PORT_ONE_CLIENT_ERROR(INTERNAL_SERVER_ERROR, "P001", "포트원 클라이언트 오류"), + SUBSCRIPTION_NOT_FOUND(NOT_FOUND, "P002", "구독 정보를 찾을 수 없습니다") + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/NotFoundException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/NotFoundException.java new file mode 100644 index 0000000..2fba169 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.synapse.payment_service.exception; + +public class NotFoundException extends PaymentException { + public NotFoundException(ExceptionCode exceptionCode) { + super(exceptionCode); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentException.java new file mode 100644 index 0000000..33facc9 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentException.java @@ -0,0 +1,13 @@ +package com.synapse.payment_service.exception; + +import lombok.Getter; + +@Getter +public abstract class PaymentException extends RuntimeException { + private final ExceptionCode exceptionCode; + + protected PaymentException(ExceptionCode exceptionCode) { + super(exceptionCode.getMessage()); + this.exceptionCode = exceptionCode; + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java new file mode 100644 index 0000000..7a4327d --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java @@ -0,0 +1,7 @@ +package com.synapse.payment_service.exception; + +public class PortOneClientException extends PaymentException { + public PortOneClientException(ExceptionCode exceptionCode) { + super(exceptionCode); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/filter/MemberAuthenticationFilter.java b/payment-service/src/main/java/com/synapse/payment_service/filter/MemberAuthenticationFilter.java new file mode 100644 index 0000000..6d75d5c --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/filter/MemberAuthenticationFilter.java @@ -0,0 +1,51 @@ +package com.synapse.payment_service.filter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MemberAuthenticationFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String memberId = request.getHeader("X-Authenticated-Member-Id"); + String memberRole = request.getHeader("X-Authenticated-Member-Role"); + + // 현재는 게이트웨이로부터 받은 데이터에 헤더가 존재할 경우 신뢰함 + if (memberId != null && !memberId.isBlank()) { + List authorities = new ArrayList<>(); + if (memberRole != null && !memberRole.isBlank()) { + authorities = Arrays.stream(memberRole.split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + UUID principal = UUID.fromString(memberId); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java new file mode 100644 index 0000000..647c263 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java @@ -0,0 +1,105 @@ +package com.synapse.payment_service.infrastructure; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import com.synapse.payment_service.config.PortOneClientProperties; +import com.synapse.payment_service.dto.PortOneAuthResponse; +import com.synapse.payment_service.dto.PortOneErrorResponse; +import com.synapse.payment_service.exception.ExceptionCode; +import com.synapse.payment_service.exception.PortOneClientException; + +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@Slf4j +public abstract class AbstractPortOneClient { + private final WebClient portOneWebClient; + private final PortOneClientProperties properties; + + private String accessToken; + private String refreshToken; + private Instant tokenExpiredAt; + + protected AbstractPortOneClient(WebClient portOneWebClient, PortOneClientProperties properties) { + this.portOneWebClient = portOneWebClient; + this.properties = properties; + } + + protected Mono performGetRequest(String uri, ParameterizedTypeReference responseType) { + String accessToken = getAccessToken(); + + return getPortOneWebClient().get() + .uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse + .bodyToMono(PortOneErrorResponse.class) + .flatMap(error -> Mono.error(new PortOneClientException(ExceptionCode.PORT_ONE_CLIENT_ERROR))) + ) + .bodyToMono(responseType); + } + + /** + * 유효한 accessToken을 반환합니다. + * @return accessToken + */ + protected final String getAccessToken() { + if(accessToken == null || tokenExpiredAt.isBefore(Instant.now().plusSeconds(60))) { + refreshToken(); + } + return this.accessToken; + } + + private void refreshToken() { + Mono authResponseMono = Mono.justOrEmpty(this.refreshToken) + .flatMap(token -> refreshWithToken()) + .switchIfEmpty(Mono.defer(this::authenticateWithSecret)); + + // 비동기 파이프라인을 실행하고 결과를 동기적으로 기다린다. + PortOneAuthResponse response = authResponseMono + .doOnSuccess(auth -> { + this.accessToken = auth.accessToken(); + this.refreshToken = auth.refreshToken(); + this.tokenExpiredAt = Instant.now().plus(29, ChronoUnit.MINUTES); + log.info("아임포트 토큰이 성공적으로 갱신되었습니다."); + }) + .block(); + + if (response == null || response.accessToken() == null) { + throw new RuntimeException("아임포트 Access/Refresh Token 발급에 실패했습니다."); + } + } + + private Mono refreshWithToken() { + return this.portOneWebClient.post() + .uri("/token/refresh") + .bodyValue(Map.of("refreshToken", this.refreshToken)) + .retrieve() + .bodyToMono(PortOneAuthResponse.class) + .onErrorResume(WebClientResponseException.class, e -> { + log.warn("리프레시 토큰 갱신 실패. 시크릿 키로 재인증을 시도합니다. 원인: {}", e.getMessage()); + return authenticateWithSecret(); + }); + } + + private Mono authenticateWithSecret() { + return this.portOneWebClient.post() + .uri("/login/api-secret") + .bodyValue(Map.of("apiSecret", this.properties.apiSecret())) + .retrieve() + .bodyToMono(PortOneAuthResponse.class); + } + + protected WebClient getPortOneWebClient() { + return this.portOneWebClient; + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java new file mode 100644 index 0000000..fdf4e8b --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java @@ -0,0 +1,30 @@ +package com.synapse.payment_service.infrastructure; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import com.siot.IamportRestClient.response.IamportResponse; +import com.synapse.payment_service.config.PortOneClientProperties; +import com.synapse.payment_service.dto.PaymentData; + +@Component +public class PortOneClient extends AbstractPortOneClient { + public PortOneClient(WebClient portOneWebClient, PortOneClientProperties properties) { + super(portOneWebClient, properties); + } + + /** + * 결제 검증용 iamport_uid로 결제 정보를 조회합니다. + * + * @param impUid 포트원 거래 고유번호 + * @return PortOneResponse 결제 정보 + * @throws com.synapse.payment_service.exception.PortOneClientException API 호출 실패 시 + */ + public IamportResponse getPaymentData(String impUid) { + String uri = "/payments/" + impUid; + + var responseType = new ParameterizedTypeReference>() {}; + return super.performGetRequest(uri, responseType).block(); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java b/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java index 6f77edb..29420ca 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java +++ b/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java @@ -1,9 +1,12 @@ package com.synapse.payment_service.repository; +import java.util.Optional; +import java.util.UUID; + import org.springframework.data.jpa.repository.JpaRepository; import com.synapse.payment_service.domain.Subscription; public interface SubscriptionRepository extends JpaRepository { - + Optional findByMemberId(UUID memberId); } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java new file mode 100644 index 0000000..63b87f1 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java @@ -0,0 +1,53 @@ +package com.synapse.payment_service.service; + +import java.math.BigDecimal; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +import com.synapse.payment_service.exception.ExceptionCode; +import com.synapse.payment_service.exception.NotFoundException; +import com.synapse.payment_service.repository.OrderRepository; +import com.synapse.payment_service.repository.SubscriptionRepository; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final SubscriptionRepository subscriptionRepository; + private final OrderRepository orderRepository; + + @Transactional + public PaymentPreparationResponse preparePayment(UUID memberId, PaymentRequestDto request) { + SubscriptionTier tier = SubscriptionTier.valueOf(request.tier().toUpperCase()); + BigDecimal amount = tier.getMonthlyPrice(); + String orderName = tier.getTierName() + "_" + "구독"; + String merchantUid = orderName + "_" + UUID.randomUUID(); + + Subscription subscription = subscriptionRepository.findByMemberId(memberId) + .orElseThrow(() -> new NotFoundException(ExceptionCode.SUBSCRIPTION_NOT_FOUND)); + + Order order = Order.builder() + .subscription(subscription) + .merchantUid(merchantUid) + .amount(amount) + .status(PaymentStatus.PENDING) + .build(); + + orderRepository.save(order); + + return new PaymentPreparationResponse(merchantUid, orderName, amount); + } + + +} diff --git a/payment-service/src/main/resources/application-local.yml b/payment-service/src/main/resources/application-local.yml index 0622468..12de5ff 100644 --- a/payment-service/src/main/resources/application-local.yml +++ b/payment-service/src/main/resources/application-local.yml @@ -18,6 +18,10 @@ spring: open-in-view: false show-sql: true +iamport: + api-secret: ${local-iamport.api-secret} + base-url: https://api.portone.io + logging: level: org: diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml index bc76a47..3d6fe2b 100644 --- a/payment-service/src/main/resources/application.yml +++ b/payment-service/src/main/resources/application.yml @@ -15,3 +15,4 @@ spring: import: - security/application-db.yml - security/application-oauth2.yml + - security/application-iamport.yml From c29229f827205e1927f045dfec8ee14facd23bf7 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 25 Jun 2025 13:46:57 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20PortOne=20V2=20SDK=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PortOne V2 공식 SDK 도입 - 결제 검증 API 추가 (/verify) - 전략 패턴 기반 결제 상태별 처리 로직 분리 - 새로운 결제 상태값 추가 및 도메인 메서드 확장 --- payment-service/build.gradle | 2 + .../config/PortOneClientConfig.java | 12 ++--- .../config/PortOneClientProperties.java | 3 +- .../controller/PaymentController.java | 12 +++++ .../synapse/payment_service/domain/Order.java | 13 ++++++ .../payment_service/domain/Subscription.java | 9 ++++ .../domain/enums/PaymentStatus.java | 6 ++- .../request/PaymentVerificationRequest.java | 13 ++++++ .../exception/ExceptionCode.java | 4 +- .../infrastructure/PortOneClient.java | 2 +- .../repository/OrderRepository.java | 4 +- .../service/PaymentService.java | 44 +++++++++++++++++++ .../DelegatingPaymentStatusConverter.java | 44 +++++++++++++++++++ .../converter/PaymentStatusConverter.java | 21 +++++++++ .../impl/CancelledPaymentConverter.java | 36 +++++++++++++++ .../impl/FailedPaymentConverter.java | 29 ++++++++++++ .../converter/impl/PaidPaymentConverter.java | 43 ++++++++++++++++++ .../PartialCancelledPaymentConverter.java | 34 ++++++++++++++ .../impl/PayPendingPaymentConverter.java | 34 ++++++++++++++ .../converter/impl/ReadyPaymentConverter.java | 33 ++++++++++++++ .../VirtualAccountIssuedPaymentConverter.java | 33 ++++++++++++++ .../src/main/resources/application-local.yml | 1 + 22 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/PaymentStatusConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java diff --git a/payment-service/build.gradle b/payment-service/build.gradle index 28c9ed5..b591fa6 100644 --- a/payment-service/build.gradle +++ b/payment-service/build.gradle @@ -21,6 +21,8 @@ repositories { dependencies { // Iam port implementation 'com.github.iamport:iamport-rest-client-java:0.2.23' + // PortOne + implementation 'io.portone:server-sdk:0.19.0' // Security implementation 'org.springframework.boot:spring-boot-starter-security' // Web diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java index 46a77fe..fc23cb4 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java @@ -3,10 +3,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.web.reactive.function.client.WebClient; +import io.portone.sdk.server.PortOneClient; import lombok.RequiredArgsConstructor; @Configuration @@ -16,10 +14,8 @@ public class PortOneClientConfig { private final PortOneClientProperties properties; @Bean - public WebClient portOneWebClient() { - return WebClient.builder() - .baseUrl(properties.baseUrl()) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .build(); + public PortOneClient portOneClient() { + // PortOneClient는 스레드에 안전하며 애플리케이션 전반에 걸쳐 재사용 가능합니다. + return new PortOneClient(properties.apiSecret(), properties.baseUrl(), properties.midKey()); } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java index e3da0d7..e7601cf 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java @@ -5,7 +5,8 @@ @ConfigurationProperties(prefix = "iamport") public record PortOneClientProperties( String apiSecret, - String baseUrl + String baseUrl, + String midKey ) { } diff --git a/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java b/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java index d6dbb44..352aaf5 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java +++ b/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RestController; import com.synapse.payment_service.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.request.PaymentVerificationRequest; import com.synapse.payment_service.dto.response.PaymentPreparationResponse; import com.synapse.payment_service.service.PaymentService; @@ -34,5 +35,16 @@ public ResponseEntity requestPayment( PaymentPreparationResponse response = paymentService.preparePayment(memberId, request); return ResponseEntity.ok().body(response); } + + /** + * 아임포트에서 결제 완료 후 호출되는 메서드로 실제로 결제가 되었는지 확인하는 API 입니다. + * @param request + * @return + */ + @PostMapping("/verify") + public ResponseEntity verifyPayment(@RequestBody @Valid PaymentVerificationRequest request) { + paymentService.verifyAndProcess(request); + return ResponseEntity.ok().build(); + } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java index aec789f..a452e4f 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java @@ -49,4 +49,17 @@ public Order(Subscription subscription, String iamportUid, String merchantUid, B this.status = status; this.paidAt = paidAt; } + + public void updateStatus(PaymentStatus status) { + this.status = status; + } + + public void updatePaymentInfo(String cardName, String cardNumber, ZonedDateTime paidAt) { + this.paidAt = paidAt; + // 카드 정보는 별도 테이블로 관리하거나 추후 추가 필요 + } + + public void updateIamportUid(String iamportUid) { + this.iamportUid = iamportUid; + } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java index ca03959..ac25694 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java @@ -50,4 +50,13 @@ public Subscription(UUID memberId, SubscriptionTier tier, int remainingChatCredi this.expiresAt = expiresAt; this.status = status; } + + public void activate() { + this.status = SubscriptionStatus.ACTIVE; + // 만료일 연장 로직 추후 추가 + } + + public void deactivate() { + this.status = SubscriptionStatus.CANCELED; + } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java index f19866e..5a2ce4c 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java @@ -4,5 +4,9 @@ public enum PaymentStatus { PAID, // 결제 완료 FAILED, // 결제 실패 CANCELLED, // 결제 취소 (환불) - PENDING // 결제 대기 + PENDING, // 결제 대기 + PARTIAL_CANCELLED, // 부분 취소 + PAY_PENDING, // 결제 완료 대기 + READY, // 준비 상태 + VIRTUAL_ACCOUNT_ISSUED // 가상계좌 발급 완료 } diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java new file mode 100644 index 0000000..06ee283 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java @@ -0,0 +1,13 @@ +package com.synapse.payment_service.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentVerificationRequest( + @NotBlank(message = "imp_uid는 필수입니다") + String impUid, + + @NotBlank(message = "merchant_uid는 필수입니다") + String merchantUid +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java index 8c733d0..3b0970f 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java @@ -12,7 +12,9 @@ @RequiredArgsConstructor public enum ExceptionCode { PORT_ONE_CLIENT_ERROR(INTERNAL_SERVER_ERROR, "P001", "포트원 클라이언트 오류"), - SUBSCRIPTION_NOT_FOUND(NOT_FOUND, "P002", "구독 정보를 찾을 수 없습니다") + + SUBSCRIPTION_NOT_FOUND(NOT_FOUND, "P002", "구독 정보를 찾을 수 없습니다"), + ORDER_NOT_FOUND(NOT_FOUND, "P003", "주문 정보를 찾을 수 없습니다") ; private final HttpStatus status; diff --git a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java index fdf4e8b..9be942a 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java +++ b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java @@ -4,8 +4,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; -import com.siot.IamportRestClient.response.IamportResponse; import com.synapse.payment_service.config.PortOneClientProperties; +import com.synapse.payment_service.dto.IamportResponse; import com.synapse.payment_service.dto.PaymentData; @Component diff --git a/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java index 80a4430..af4651a 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java +++ b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java @@ -1,9 +1,11 @@ package com.synapse.payment_service.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.synapse.payment_service.domain.Order; public interface OrderRepository extends JpaRepository { - + Optional findByMerchantUid(String merchantUid); } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java index 63b87f1..abe261e 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java @@ -10,15 +10,24 @@ import com.synapse.payment_service.domain.Subscription; import com.synapse.payment_service.domain.enums.PaymentStatus; import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.dto.IamportResponse; +import com.synapse.payment_service.dto.PaymentData; import com.synapse.payment_service.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.request.PaymentVerificationRequest; import com.synapse.payment_service.dto.response.PaymentPreparationResponse; import com.synapse.payment_service.exception.ExceptionCode; import com.synapse.payment_service.exception.NotFoundException; import com.synapse.payment_service.repository.OrderRepository; import com.synapse.payment_service.repository.SubscriptionRepository; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.webhook.WebhookVerifier; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Transactional(readOnly = true) @Service @RequiredArgsConstructor @@ -26,6 +35,9 @@ public class PaymentService { private final SubscriptionRepository subscriptionRepository; private final OrderRepository orderRepository; + private final PortOneClient portOneClient; + private final PaymentStatusConverter paymentStatusConverter; + private final WebhookVerifier webhookVerifier; // 웹훅 검증용 @Transactional public PaymentPreparationResponse preparePayment(UUID memberId, PaymentRequestDto request) { @@ -49,5 +61,37 @@ public PaymentPreparationResponse preparePayment(UUID memberId, PaymentRequestDt return new PaymentPreparationResponse(merchantUid, orderName, amount); } + @Transactional + public void verifyAndProcess(PaymentVerificationRequest request) { + Order order = orderRepository.findByMerchantUid(request.merchantUid()) + .orElseThrow(() -> new NotFoundException(ExceptionCode.ORDER_NOT_FOUND)); + + if (order.getStatus() != PaymentStatus.PENDING) { + log.info("이미 처리된 결제입니다. merchantUid={}", order.getMerchantUid()); + return; + } + + Payment payment = portOneClient.getPayment().getPayment(request.impUid()).join(); + + if (payment == null) { + throw new RuntimeException("포트원 결제 정보 조회 실패"); + } + + // 아임포트 결제 ID 설정 + order.updateIamportUid(request.impUid()); + + if (!(payment instanceof Payment.Recognized recognizedPayment)) { + throw new RuntimeException("결제 정보를 인식할 수 없습니다"); + } + + if (order.getAmount().compareTo(BigDecimal.valueOf(recognizedPayment.getAmount().getTotal())) != 0) { + log.error("결제 금액 불일치. 주문금액={}, 실제결제금액={}, merchantUid={}", + order.getAmount(), recognizedPayment.getAmount().getTotal(), order.getMerchantUid()); + throw new RuntimeException("결제 금액이 불일치합니다. 결제를 취소합니다."); + } + + // DelegatingConverter를 통해 결제 상태별 처리 위임 + paymentStatusConverter.processPayment(order, payment); + } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java new file mode 100644 index 0000000..88c082d --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java @@ -0,0 +1,44 @@ +package com.synapse.payment_service.service.converter; + +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +import com.synapse.payment_service.domain.Order; + +import io.portone.sdk.server.payment.Payment; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DelegatingPaymentStatusConverter implements PaymentStatusConverter { + + private final List converters; + + @Override + public boolean canHandle(Class paymentStatus) { + return converters.stream() + .anyMatch(converter -> converter.canHandle(paymentStatus)); + } + + @Override + public void processPayment(Order order, Payment payment) { + Assert.notNull(order, "order cannot be null"); + Assert.notNull(payment, "payment cannot be null"); + + for (PaymentStatusConverter converter : this.converters) { + if (converter.canHandle(payment.getClass())) { + log.info("결제 상태 처리: {} -> {}", payment.getClass(), converter.getClass().getSimpleName()); + converter.processPayment(order, payment); + return; + } + } + + // 처리할 수 있는 컨버터가 없는 경우 + log.error("지원하지 않는 결제 상태: {}", payment.getClass()); + throw new IllegalArgumentException("지원하지 않는 결제 상태: " + payment.getClass()); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/PaymentStatusConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/PaymentStatusConverter.java new file mode 100644 index 0000000..f3b8e58 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/PaymentStatusConverter.java @@ -0,0 +1,21 @@ +package com.synapse.payment_service.service.converter; + +import com.synapse.payment_service.domain.Order; + +import io.portone.sdk.server.payment.Payment; + +/** + * 결제 상태별 처리를 담당하는 컨버터 인터페이스 + */ +public interface PaymentStatusConverter { + + /** + * 해당 컨버터가 주어진 결제 상태를 처리할 수 있는지 확인 + */ + boolean canHandle(Class paymentStatus); + + /** + * 결제 상태에 따른 주문 처리 로직 실행 + */ + void processPayment(Order order, Payment payment); +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java new file mode 100644 index 0000000..e54d680 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java @@ -0,0 +1,36 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.CancelledPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class CancelledPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(CancelledPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("결제 취소 처리 시작. merchantUid={}", order.getMerchantUid()); + + // 주문 상태를 취소로 업데이트 + order.updateStatus(PaymentStatus.CANCELLED); + + // 구독 비활성화 + order.getSubscription().deactivate(); + + log.info("결제 취소 처리 완료. merchantUid={}", order.getMerchantUid()); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java new file mode 100644 index 0000000..c3d00e7 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java @@ -0,0 +1,29 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.FailedPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class FailedPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(FailedPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + // 주문 상태를 실패로 업데이트 + order.updateStatus(PaymentStatus.FAILED); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java new file mode 100644 index 0000000..54ccf89 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java @@ -0,0 +1,43 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.dto.PaymentData; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.PaidPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class PaidPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(PaidPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + if (!(payment instanceof Payment.Recognized recognizedPayment)) { + throw new RuntimeException("결제 정보를 인식할 수 없습니다"); + } + log.info("결제 완료 처리 시작. merchantUid={}, amount={}", + order.getMerchantUid(), recognizedPayment.getAmount().getTotal()); + + // 주문 상태 업데이트 + order.updateStatus(PaymentStatus.PAID); + + // 구독 상태 활성화 + Subscription subscription = order.getSubscription(); + subscription.activate(); + + log.info("결제 완료 처리 완료. merchantUid={}", order.getMerchantUid()); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java new file mode 100644 index 0000000..a0220d6 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java @@ -0,0 +1,34 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.PartialCancelledPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class PartialCancelledPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(PartialCancelledPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("부분 취소 처리 시작. merchantUid={}", order.getMerchantUid()); + + // 주문 상태를 부분 취소로 업데이트 + order.updateStatus(PaymentStatus.PARTIAL_CANCELLED); + + // 부분 취소 시에는 구독은 유지하되 크레딧 조정 등의 로직이 필요할 수 있음 + log.info("부분 취소 처리 완료. merchantUid={}", order.getMerchantUid()); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java new file mode 100644 index 0000000..0e01c6f --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java @@ -0,0 +1,34 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.dto.PaymentData; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.PayPendingPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class PayPendingPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(PayPendingPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("결제 완료 대기 처리 시작. merchantUid={}", order.getMerchantUid()); + + // 주문 상태를 결제 완료 대기로 업데이트 + order.updateStatus(PaymentStatus.PAY_PENDING); + + log.info("결제 완료 대기 처리 완료. merchantUid={}", order.getMerchantUid()); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java new file mode 100644 index 0000000..70e0ae7 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java @@ -0,0 +1,33 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.payment.ReadyPayment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class ReadyPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(ReadyPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("결제 준비 상태 처리 시작. merchantUid={}", order.getMerchantUid()); + + // 주문 상태를 준비로 업데이트 + order.updateStatus(PaymentStatus.READY); + + log.info("결제 준비 상태 처리 완료. merchantUid={}", order.getMerchantUid()); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java new file mode 100644 index 0000000..15ff846 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java @@ -0,0 +1,33 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.payment.VirtualAccountIssuedPayment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class VirtualAccountIssuedPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(VirtualAccountIssuedPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("가상계좌 발급 완료 처리 시작. merchantUid={}", order.getMerchantUid()); + + // 주문 상태를 가상계좌 발급 완료로 업데이트 + order.updateStatus(PaymentStatus.VIRTUAL_ACCOUNT_ISSUED); + + log.info("가상계좌 발급 완료 처리 완료. merchantUid={}", order.getMerchantUid()); + } +} \ No newline at end of file diff --git a/payment-service/src/main/resources/application-local.yml b/payment-service/src/main/resources/application-local.yml index 12de5ff..42a4c20 100644 --- a/payment-service/src/main/resources/application-local.yml +++ b/payment-service/src/main/resources/application-local.yml @@ -21,6 +21,7 @@ spring: iamport: api-secret: ${local-iamport.api-secret} base-url: https://api.portone.io + mid-key: ${local-iamport.mid-key} logging: level: From a33ade793759acd3b83c60bbf501346382a50524 Mon Sep 17 00:00:00 2001 From: Kang Dong Hyeon Date: Thu, 26 Jun 2025 00:18:54 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20PortOne=20SDK=20v2=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9B=B9=ED=9B=85=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PortOne SDK v2버전으로 구현 - 웹훅 처리를 위한 PaymentWebhookRequest record 구현 - 결제 완료 시 구독 티어 자동 업그레이드 로직 추가 - PaymentStatusConverter 팩토리 패턴으로 리팩토링 - 통합 테스트 환경 구축 및 전체 결제 플로우 검증 --- payment-service/build.gradle | 2 - .../config/ObjectMapperConfig.java | 18 ++ .../config/PortOneClientConfig.java | 7 +- .../config/PortOneClientProperties.java | 3 +- .../config/ResourceServerConfig.java | 6 +- .../controller/WebHookController.java | 36 ++++ .../synapse/payment_service/domain/Order.java | 19 +- .../payment_service/domain/Subscription.java | 6 +- .../payment_service/dto/IamportResponse.java | 14 -- .../payment_service/dto/PaymentData.java | 31 --- .../dto/PortOneAuthResponse.java | 8 - .../dto/PortOneErrorResponse.java | 14 -- .../request/PaymentVerificationRequest.java | 8 +- .../dto/request/PaymentWebhookRequest.java | 32 ++++ .../response/PaymentPreparationResponse.java | 2 +- .../exception/ExceptionCode.java | 9 +- .../PaymentVerificationException.java | 7 + .../exception/PortOneClientException.java | 7 - .../infrastructure/AbstractPortOneClient.java | 105 ---------- .../infrastructure/PortOneClient.java | 30 --- .../repository/OrderRepository.java | 2 +- .../service/PaymentService.java | 51 +++-- .../DelegatingPaymentStatusConverter.java | 31 ++- .../impl/CancelledPaymentConverter.java | 8 +- .../impl/FailedPaymentConverter.java | 4 +- .../converter/impl/PaidPaymentConverter.java | 21 +- .../PartialCancelledPaymentConverter.java | 8 +- .../impl/PayPendingPaymentConverter.java | 9 +- .../converter/impl/ReadyPaymentConverter.java | 8 +- .../VirtualAccountIssuedPaymentConverter.java | 8 +- .../src/main/resources/application-local.yml | 1 + .../PaymentIntegrationTest.java | 174 +++++++++++++++++ .../ResourceServerIntegrationTest.java | 5 + .../service/PaymentServiceTest.java | 179 ++++++++++++++++++ .../src/test/resources/application-test.yml | 6 + 35 files changed, 577 insertions(+), 302 deletions(-) create mode 100644 payment-service/src/main/java/com/synapse/payment_service/config/ObjectMapperConfig.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/controller/WebHookController.java delete mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java delete mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java delete mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java delete mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java delete mode 100644 payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java delete mode 100644 payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java delete mode 100644 payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java create mode 100644 payment-service/src/test/java/com/synapse/payment_service/integrationtest/PaymentIntegrationTest.java create mode 100644 payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java diff --git a/payment-service/build.gradle b/payment-service/build.gradle index b591fa6..a1e163a 100644 --- a/payment-service/build.gradle +++ b/payment-service/build.gradle @@ -19,8 +19,6 @@ repositories { } dependencies { - // Iam port - implementation 'com.github.iamport:iamport-rest-client-java:0.2.23' // PortOne implementation 'io.portone:server-sdk:0.19.0' // Security diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/ObjectMapperConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/ObjectMapperConfig.java new file mode 100644 index 0000000..41f0f24 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/ObjectMapperConfig.java @@ -0,0 +1,18 @@ +package com.synapse.payment_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java index fc23cb4..04c8295 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java @@ -5,6 +5,7 @@ import org.springframework.context.annotation.Configuration; import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.webhook.WebhookVerifier; import lombok.RequiredArgsConstructor; @Configuration @@ -15,7 +16,11 @@ public class PortOneClientConfig { @Bean public PortOneClient portOneClient() { - // PortOneClient는 스레드에 안전하며 애플리케이션 전반에 걸쳐 재사용 가능합니다. return new PortOneClient(properties.apiSecret(), properties.baseUrl(), properties.midKey()); } + + @Bean + public WebhookVerifier webhookVerifier() { + return new WebhookVerifier(properties.webhookSecret()); + } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java index e7601cf..a9b1f44 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java @@ -6,7 +6,8 @@ public record PortOneClientProperties( String apiSecret, String baseUrl, - String midKey + String midKey, + String webhookSecret ) { } diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java index 914c2a7..807fcd0 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java @@ -35,12 +35,14 @@ public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .securityMatcher("/api/internal/**") .authorizeHttpRequests(authorize -> authorize - .anyRequest().access(AuthorizationManagers.allOf( + .requestMatchers("/api/webhooks/**").permitAll() // 웹훅은 인증 없이 허용 + .requestMatchers("/api/payments/**").authenticated() // 결제 API는 인증 필요 + .requestMatchers("/api/internal/**").access(AuthorizationManagers.allOf( AuthorityAuthorizationManager.hasAuthority("SCOPE_api.internal"), AuthorityAuthorizationManager.hasAuthority("SCOPE_account:read") )) + .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())) .addFilterBefore(memberAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/payment-service/src/main/java/com/synapse/payment_service/controller/WebHookController.java b/payment-service/src/main/java/com/synapse/payment_service/controller/WebHookController.java new file mode 100644 index 0000000..b82687d --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/controller/WebHookController.java @@ -0,0 +1,36 @@ +package com.synapse.payment_service.controller; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.synapse.payment_service.service.PaymentService; + +import io.portone.sdk.server.errors.WebhookVerificationException; +import io.portone.sdk.server.webhook.WebhookVerifier; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/webhooks") +@RequiredArgsConstructor +public class WebHookController { + private final WebhookVerifier webhookVerifier; + private final PaymentService paymentService; + + @PostMapping("/portone") + public ResponseEntity handlePortOneWebhook( + @RequestBody String requestBody, + @RequestHeader(WebhookVerifier.HEADER_ID) String webhookId, + @RequestHeader(WebhookVerifier.HEADER_SIGNATURE) String webhookSignature, + @RequestHeader(WebhookVerifier.HEADER_TIMESTAMP) String webhookTimestamp + ) throws WebhookVerificationException, IOException { + webhookVerifier.verify(requestBody, webhookId, webhookSignature, webhookTimestamp); + paymentService.verifyAndProcessWebhook(requestBody); + return ResponseEntity.ok().build(); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java index a452e4f..7cda86d 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java @@ -26,10 +26,10 @@ public class Order extends BaseEntity { private Subscription subscription; @Column(unique = true) - private String iamportUid; // 아임포트에서 사용하는 결제 건별 고유 ID, 환불시 사용 + private String iamPortTransactionId; // 아임포트에서 사용하는 결제 건별 고유 ID, 환불시 사용 @Column(nullable = false, unique = true) - private String merchantUid; // 주문별 고유 ID. 중복 결제 방지 + private String paymentId; // 주문별 고유 ID. 중복 결제 방지 @Column(nullable = false) private BigDecimal amount; @@ -41,10 +41,10 @@ public class Order extends BaseEntity { private ZonedDateTime paidAt; @Builder - public Order(Subscription subscription, String iamportUid, String merchantUid, BigDecimal amount, PaymentStatus status, ZonedDateTime paidAt) { + public Order(Subscription subscription, String iamPortTransactionId, String paymentId, BigDecimal amount, PaymentStatus status, ZonedDateTime paidAt) { this.subscription = subscription; - this.iamportUid = iamportUid; - this.merchantUid = merchantUid; + this.iamPortTransactionId = iamPortTransactionId; + this.paymentId = paymentId; this.amount = amount; this.status = status; this.paidAt = paidAt; @@ -54,12 +54,7 @@ public void updateStatus(PaymentStatus status) { this.status = status; } - public void updatePaymentInfo(String cardName, String cardNumber, ZonedDateTime paidAt) { - this.paidAt = paidAt; - // 카드 정보는 별도 테이블로 관리하거나 추후 추가 필요 - } - - public void updateIamportUid(String iamportUid) { - this.iamportUid = iamportUid; + public void updateIamPortTransactionId(String iamPortTransactionId) { + this.iamPortTransactionId = iamPortTransactionId; } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java index ac25694..1ed2465 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java @@ -50,10 +50,10 @@ public Subscription(UUID memberId, SubscriptionTier tier, int remainingChatCredi this.expiresAt = expiresAt; this.status = status; } - - public void activate() { + + public void activate(SubscriptionTier newTier) { this.status = SubscriptionStatus.ACTIVE; - // 만료일 연장 로직 추후 추가 + this.tier = newTier; } public void deactivate() { diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java deleted file mode 100644 index 192fdee..0000000 --- a/payment-service/src/main/java/com/synapse/payment_service/dto/IamportResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.synapse.payment_service.dto; - -/** - * 아임포트 API의 공통적인 응답 래퍼(Wrapper) 클래스입니다. - * - * @param 실제 데이터의 타입 (예: PaymentData) - */ -public record IamportResponse( - int code, - String message, - T response -) { - -} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java b/payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java deleted file mode 100644 index 18f05f7..0000000 --- a/payment-service/src/main/java/com/synapse/payment_service/dto/PaymentData.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.synapse.payment_service.dto; - -import java.math.BigDecimal; -import java.time.ZonedDateTime; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record PaymentData( - BigDecimal amount, - - // 결제 성공/실패/취소 여부를 판단하기 위한 상태 - String status, - - // 우리 시스템의 주문 정보와 매칭하기 위한 주문번호 - @JsonProperty("merchant_uid") String merchantUid, - - // 실패 시 원인 파악을 위한 필드 - @JsonProperty("fail_reason") String failReason, - - // 고객에게 보여줄 결제 수단 정보 - @JsonProperty("card_name") String cardName, - - @JsonProperty("card_number") String cardNumber, - - //결제 완료 시각 - ZonedDateTime paidAt -) { - -} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java deleted file mode 100644 index 584bd03..0000000 --- a/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneAuthResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.synapse.payment_service.dto; - -public record PortOneAuthResponse( - String accessToken, - String refreshToken -) { - -} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java deleted file mode 100644 index 915c952..0000000 --- a/payment-service/src/main/java/com/synapse/payment_service/dto/PortOneErrorResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.synapse.payment_service.dto; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record PortOneErrorResponse( - ErrorDetails error -) { - public record ErrorDetails( - String code, String message - ) { - - } -} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java index 06ee283..8d148b4 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java @@ -3,11 +3,11 @@ import jakarta.validation.constraints.NotBlank; public record PaymentVerificationRequest( - @NotBlank(message = "imp_uid는 필수입니다") - String impUid, + @NotBlank(message = "paymentId는 필수입니다") + String paymentId, - @NotBlank(message = "merchant_uid는 필수입니다") - String merchantUid + @NotBlank(message = "iamPortTransactionId는 필수입니다") + String iamPortTransactionId ) { } diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java new file mode 100644 index 0000000..e0e0e79 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java @@ -0,0 +1,32 @@ +package com.synapse.payment_service.dto.request; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.Optional; + +public record PaymentWebhookRequest( + String paymentId, + String transactionId, + String type +) { + public static PaymentWebhookRequest from(String requestBody, ObjectMapper objectMapper) throws IOException { + JsonNode root = objectMapper.readTree(requestBody); + + // data 노드가 있으면 그 안에서, 없으면 루트에서 찾기 + JsonNode dataNode = Optional.ofNullable(root.get("data")).orElse(root); + + return new PaymentWebhookRequest( + extractText(dataNode, "paymentId"), + extractText(dataNode, "transactionId"), + extractText(root, "type") // type은 항상 루트 레벨에 있음 + ); + } + + private static String extractText(JsonNode node, String fieldName) { + return Optional.ofNullable(node.get(fieldName)) + .map(JsonNode::asText) + .orElse(null); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java index ec23dd6..5f5b4ce 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java @@ -3,7 +3,7 @@ import java.math.BigDecimal; public record PaymentPreparationResponse( - String merchantUid, + String paymentId, String orderName, BigDecimal amount ) { diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java index 3b0970f..bf3c82f 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java @@ -7,6 +7,8 @@ import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.BAD_REQUEST; @Getter @RequiredArgsConstructor @@ -14,7 +16,12 @@ public enum ExceptionCode { PORT_ONE_CLIENT_ERROR(INTERNAL_SERVER_ERROR, "P001", "포트원 클라이언트 오류"), SUBSCRIPTION_NOT_FOUND(NOT_FOUND, "P002", "구독 정보를 찾을 수 없습니다"), - ORDER_NOT_FOUND(NOT_FOUND, "P003", "주문 정보를 찾을 수 없습니다") + ORDER_NOT_FOUND(NOT_FOUND, "P003", "주문 정보를 찾을 수 없습니다"), + + PAYMENT_VERIFICATION_FAILED(BAD_REQUEST, "P004", "존재하지 않는 거래입니다"), + PAYMENT_NOT_RECOGNIZED(INTERNAL_SERVER_ERROR, "P005", "결제 정보를 인식할 수 없습니다"), + PAYMENT_AMOUNT_MISMATCH(CONFLICT, "P006", "결제 금액이 불일치합니다"), + UNSUPPORTED_PAYMENT_STATUS(INTERNAL_SERVER_ERROR, "P007", "지원하지 않는 결제 상태입니다") ; private final HttpStatus status; diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java new file mode 100644 index 0000000..d0c8b0b --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java @@ -0,0 +1,7 @@ +package com.synapse.payment_service.exception; + +public class PaymentVerificationException extends PaymentException { + public PaymentVerificationException(ExceptionCode exceptionCode) { + super(exceptionCode); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java deleted file mode 100644 index 7a4327d..0000000 --- a/payment-service/src/main/java/com/synapse/payment_service/exception/PortOneClientException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.synapse.payment_service.exception; - -public class PortOneClientException extends PaymentException { - public PortOneClientException(ExceptionCode exceptionCode) { - super(exceptionCode); - } -} diff --git a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java deleted file mode 100644 index 647c263..0000000 --- a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/AbstractPortOneClient.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.synapse.payment_service.infrastructure; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Map; - -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpHeaders; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; - -import com.synapse.payment_service.config.PortOneClientProperties; -import com.synapse.payment_service.dto.PortOneAuthResponse; -import com.synapse.payment_service.dto.PortOneErrorResponse; -import com.synapse.payment_service.exception.ExceptionCode; -import com.synapse.payment_service.exception.PortOneClientException; - -import lombok.extern.slf4j.Slf4j; -import reactor.core.publisher.Mono; - -@Slf4j -public abstract class AbstractPortOneClient { - private final WebClient portOneWebClient; - private final PortOneClientProperties properties; - - private String accessToken; - private String refreshToken; - private Instant tokenExpiredAt; - - protected AbstractPortOneClient(WebClient portOneWebClient, PortOneClientProperties properties) { - this.portOneWebClient = portOneWebClient; - this.properties = properties; - } - - protected Mono performGetRequest(String uri, ParameterizedTypeReference responseType) { - String accessToken = getAccessToken(); - - return getPortOneWebClient().get() - .uri(uri) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .retrieve() - .onStatus( - status -> status.is4xxClientError() || status.is5xxServerError(), - clientResponse -> clientResponse - .bodyToMono(PortOneErrorResponse.class) - .flatMap(error -> Mono.error(new PortOneClientException(ExceptionCode.PORT_ONE_CLIENT_ERROR))) - ) - .bodyToMono(responseType); - } - - /** - * 유효한 accessToken을 반환합니다. - * @return accessToken - */ - protected final String getAccessToken() { - if(accessToken == null || tokenExpiredAt.isBefore(Instant.now().plusSeconds(60))) { - refreshToken(); - } - return this.accessToken; - } - - private void refreshToken() { - Mono authResponseMono = Mono.justOrEmpty(this.refreshToken) - .flatMap(token -> refreshWithToken()) - .switchIfEmpty(Mono.defer(this::authenticateWithSecret)); - - // 비동기 파이프라인을 실행하고 결과를 동기적으로 기다린다. - PortOneAuthResponse response = authResponseMono - .doOnSuccess(auth -> { - this.accessToken = auth.accessToken(); - this.refreshToken = auth.refreshToken(); - this.tokenExpiredAt = Instant.now().plus(29, ChronoUnit.MINUTES); - log.info("아임포트 토큰이 성공적으로 갱신되었습니다."); - }) - .block(); - - if (response == null || response.accessToken() == null) { - throw new RuntimeException("아임포트 Access/Refresh Token 발급에 실패했습니다."); - } - } - - private Mono refreshWithToken() { - return this.portOneWebClient.post() - .uri("/token/refresh") - .bodyValue(Map.of("refreshToken", this.refreshToken)) - .retrieve() - .bodyToMono(PortOneAuthResponse.class) - .onErrorResume(WebClientResponseException.class, e -> { - log.warn("리프레시 토큰 갱신 실패. 시크릿 키로 재인증을 시도합니다. 원인: {}", e.getMessage()); - return authenticateWithSecret(); - }); - } - - private Mono authenticateWithSecret() { - return this.portOneWebClient.post() - .uri("/login/api-secret") - .bodyValue(Map.of("apiSecret", this.properties.apiSecret())) - .retrieve() - .bodyToMono(PortOneAuthResponse.class); - } - - protected WebClient getPortOneWebClient() { - return this.portOneWebClient; - } -} diff --git a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java b/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java deleted file mode 100644 index 9be942a..0000000 --- a/payment-service/src/main/java/com/synapse/payment_service/infrastructure/PortOneClient.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.synapse.payment_service.infrastructure; - -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; - -import com.synapse.payment_service.config.PortOneClientProperties; -import com.synapse.payment_service.dto.IamportResponse; -import com.synapse.payment_service.dto.PaymentData; - -@Component -public class PortOneClient extends AbstractPortOneClient { - public PortOneClient(WebClient portOneWebClient, PortOneClientProperties properties) { - super(portOneWebClient, properties); - } - - /** - * 결제 검증용 iamport_uid로 결제 정보를 조회합니다. - * - * @param impUid 포트원 거래 고유번호 - * @return PortOneResponse 결제 정보 - * @throws com.synapse.payment_service.exception.PortOneClientException API 호출 실패 시 - */ - public IamportResponse getPaymentData(String impUid) { - String uri = "/payments/" + impUid; - - var responseType = new ParameterizedTypeReference>() {}; - return super.performGetRequest(uri, responseType).block(); - } -} diff --git a/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java index af4651a..0558632 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java +++ b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java @@ -7,5 +7,5 @@ import com.synapse.payment_service.domain.Order; public interface OrderRepository extends JpaRepository { - Optional findByMerchantUid(String merchantUid); + Optional findByPaymentId(String paymentId); } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java index abe261e..b0553f9 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java @@ -1,5 +1,6 @@ package com.synapse.payment_service.service; +import java.io.IOException; import java.math.BigDecimal; import java.util.UUID; @@ -10,22 +11,22 @@ import com.synapse.payment_service.domain.Subscription; import com.synapse.payment_service.domain.enums.PaymentStatus; import com.synapse.payment_service.domain.enums.SubscriptionTier; -import com.synapse.payment_service.dto.IamportResponse; -import com.synapse.payment_service.dto.PaymentData; import com.synapse.payment_service.dto.request.PaymentRequestDto; import com.synapse.payment_service.dto.request.PaymentVerificationRequest; +import com.synapse.payment_service.dto.request.PaymentWebhookRequest; import com.synapse.payment_service.dto.response.PaymentPreparationResponse; import com.synapse.payment_service.exception.ExceptionCode; import com.synapse.payment_service.exception.NotFoundException; +import com.synapse.payment_service.exception.PaymentVerificationException; import com.synapse.payment_service.repository.OrderRepository; import com.synapse.payment_service.repository.SubscriptionRepository; import com.synapse.payment_service.service.converter.PaymentStatusConverter; import io.portone.sdk.server.PortOneClient; import io.portone.sdk.server.payment.Payment; -import io.portone.sdk.server.webhook.WebhookVerifier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import com.fasterxml.jackson.databind.ObjectMapper; @Slf4j @Transactional(readOnly = true) @@ -37,61 +38,71 @@ public class PaymentService { private final OrderRepository orderRepository; private final PortOneClient portOneClient; private final PaymentStatusConverter paymentStatusConverter; - private final WebhookVerifier webhookVerifier; // 웹훅 검증용 + private final ObjectMapper objectMapper; @Transactional public PaymentPreparationResponse preparePayment(UUID memberId, PaymentRequestDto request) { SubscriptionTier tier = SubscriptionTier.valueOf(request.tier().toUpperCase()); BigDecimal amount = tier.getMonthlyPrice(); - String orderName = tier.getTierName() + "_" + "구독"; - String merchantUid = orderName + "_" + UUID.randomUUID(); + String orderName = tier.getTierName() + "_" + "subscription"; + String paymentId = orderName + "_" + UUID.randomUUID(); Subscription subscription = subscriptionRepository.findByMemberId(memberId) - .orElseThrow(() -> new NotFoundException(ExceptionCode.SUBSCRIPTION_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(ExceptionCode.SUBSCRIPTION_NOT_FOUND)); // 현재 인증 서버와 연동이 안되어있기 때문에 테스트로 검증 Order order = Order.builder() .subscription(subscription) - .merchantUid(merchantUid) + .paymentId(paymentId) .amount(amount) .status(PaymentStatus.PENDING) .build(); orderRepository.save(order); - return new PaymentPreparationResponse(merchantUid, orderName, amount); + return new PaymentPreparationResponse(paymentId, orderName, amount); } @Transactional public void verifyAndProcess(PaymentVerificationRequest request) { - Order order = orderRepository.findByMerchantUid(request.merchantUid()) + processPaymentVerification(request.paymentId(), request.iamPortTransactionId()); + } + + + // 웹 훅 용입니다. + @Transactional + public void verifyAndProcessWebhook(String requestBody) throws IOException { + PaymentWebhookRequest webhookRequest = PaymentWebhookRequest.from(requestBody, objectMapper); + processPaymentVerification(webhookRequest.paymentId(), webhookRequest.transactionId()); + } + + private void processPaymentVerification(String paymentId, String iamPortTransactionId) { + Order order = orderRepository.findByPaymentId(paymentId) .orElseThrow(() -> new NotFoundException(ExceptionCode.ORDER_NOT_FOUND)); if (order.getStatus() != PaymentStatus.PENDING) { - log.info("이미 처리된 결제입니다. merchantUid={}", order.getMerchantUid()); + log.info("이미 처리된 결제입니다. paymentId={}", order.getPaymentId()); return; } - Payment payment = portOneClient.getPayment().getPayment(request.impUid()).join(); + Payment payment = portOneClient.getPayment().getPayment(iamPortTransactionId).join(); if (payment == null) { - throw new RuntimeException("포트원 결제 정보 조회 실패"); + throw new PaymentVerificationException(ExceptionCode.PAYMENT_VERIFICATION_FAILED); } // 아임포트 결제 ID 설정 - order.updateIamportUid(request.impUid()); + order.updateIamPortTransactionId(iamPortTransactionId); if (!(payment instanceof Payment.Recognized recognizedPayment)) { - throw new RuntimeException("결제 정보를 인식할 수 없습니다"); + throw new PaymentVerificationException(ExceptionCode.PAYMENT_NOT_RECOGNIZED); } if (order.getAmount().compareTo(BigDecimal.valueOf(recognizedPayment.getAmount().getTotal())) != 0) { - log.error("결제 금액 불일치. 주문금액={}, 실제결제금액={}, merchantUid={}", - order.getAmount(), recognizedPayment.getAmount().getTotal(), order.getMerchantUid()); - throw new RuntimeException("결제 금액이 불일치합니다. 결제를 취소합니다."); + log.error("결제 금액 불일치. 주문금액={}, 실제결제금액={}, paymentId={}", + order.getAmount(), recognizedPayment.getAmount().getTotal(), order.getPaymentId()); + throw new PaymentVerificationException(ExceptionCode.PAYMENT_AMOUNT_MISMATCH); } - // DelegatingConverter를 통해 결제 상태별 처리 위임 paymentStatusConverter.processPayment(order, payment); } - } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java index 88c082d..b495b7e 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java @@ -1,23 +1,46 @@ package com.synapse.payment_service.service.converter; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; import java.util.List; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.exception.ExceptionCode; +import com.synapse.payment_service.exception.PaymentVerificationException; +import com.synapse.payment_service.service.converter.impl.CancelledPaymentConverter; +import com.synapse.payment_service.service.converter.impl.FailedPaymentConverter; +import com.synapse.payment_service.service.converter.impl.PaidPaymentConverter; +import com.synapse.payment_service.service.converter.impl.PartialCancelledPaymentConverter; +import com.synapse.payment_service.service.converter.impl.PayPendingPaymentConverter; +import com.synapse.payment_service.service.converter.impl.ReadyPaymentConverter; +import com.synapse.payment_service.service.converter.impl.VirtualAccountIssuedPaymentConverter; import io.portone.sdk.server.payment.Payment; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Component -@RequiredArgsConstructor public class DelegatingPaymentStatusConverter implements PaymentStatusConverter { private final List converters; + public DelegatingPaymentStatusConverter() { + List paymentConverters = Arrays.asList( + new PaidPaymentConverter(), + new CancelledPaymentConverter(), + new PartialCancelledPaymentConverter(), + new PayPendingPaymentConverter(), + new ReadyPaymentConverter(), + new VirtualAccountIssuedPaymentConverter(), + new FailedPaymentConverter() + ); + this.converters = Collections.unmodifiableList(new LinkedList<>(paymentConverters)); + } + @Override public boolean canHandle(Class paymentStatus) { return converters.stream() @@ -39,6 +62,6 @@ public void processPayment(Order order, Payment payment) { // 처리할 수 있는 컨버터가 없는 경우 log.error("지원하지 않는 결제 상태: {}", payment.getClass()); - throw new IllegalArgumentException("지원하지 않는 결제 상태: " + payment.getClass()); + throw new PaymentVerificationException(ExceptionCode.UNSUPPORTED_PAYMENT_STATUS); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java index e54d680..40150c9 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java @@ -1,6 +1,5 @@ package com.synapse.payment_service.service.converter.impl; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.domain.Order; @@ -12,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Component public class CancelledPaymentConverter implements PaymentStatusConverter { @Override @@ -23,7 +21,7 @@ public boolean canHandle(Class paymentStatus) { @Override @Transactional public void processPayment(Order order, Payment payment) { - log.info("결제 취소 처리 시작. merchantUid={}", order.getMerchantUid()); + log.info("결제 취소 처리 시작. paymentId={}", order.getPaymentId()); // 주문 상태를 취소로 업데이트 order.updateStatus(PaymentStatus.CANCELLED); @@ -31,6 +29,6 @@ public void processPayment(Order order, Payment payment) { // 구독 비활성화 order.getSubscription().deactivate(); - log.info("결제 취소 처리 완료. merchantUid={}", order.getMerchantUid()); + log.info("결제 취소 처리 완료. paymentId={}", order.getPaymentId()); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java index c3d00e7..92a4bb0 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java @@ -1,6 +1,5 @@ package com.synapse.payment_service.service.converter.impl; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.domain.Order; @@ -12,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Component public class FailedPaymentConverter implements PaymentStatusConverter { @Override @@ -26,4 +24,4 @@ public void processPayment(Order order, Payment payment) { // 주문 상태를 실패로 업데이트 order.updateStatus(PaymentStatus.FAILED); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java index 54ccf89..2f52ae6 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java @@ -1,12 +1,13 @@ package com.synapse.payment_service.service.converter.impl; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.domain.Order; import com.synapse.payment_service.domain.Subscription; import com.synapse.payment_service.domain.enums.PaymentStatus; -import com.synapse.payment_service.dto.PaymentData; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.exception.ExceptionCode; +import com.synapse.payment_service.exception.PaymentVerificationException; import com.synapse.payment_service.service.converter.PaymentStatusConverter; import io.portone.sdk.server.payment.PaidPayment; @@ -14,7 +15,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Component public class PaidPaymentConverter implements PaymentStatusConverter { @Override @@ -26,18 +26,15 @@ public boolean canHandle(Class paymentStatus) { @Transactional public void processPayment(Order order, Payment payment) { if (!(payment instanceof Payment.Recognized recognizedPayment)) { - throw new RuntimeException("결제 정보를 인식할 수 없습니다"); + throw new PaymentVerificationException(ExceptionCode.PAYMENT_NOT_RECOGNIZED); } - log.info("결제 완료 처리 시작. merchantUid={}, amount={}", - order.getMerchantUid(), recognizedPayment.getAmount().getTotal()); + log.info("결제 완료 처리 시작. paymentId={}, amount={}", + order.getPaymentId(), recognizedPayment.getAmount().getTotal()); - // 주문 상태 업데이트 order.updateStatus(PaymentStatus.PAID); - - // 구독 상태 활성화 Subscription subscription = order.getSubscription(); - subscription.activate(); + subscription.activate(SubscriptionTier.PRO); - log.info("결제 완료 처리 완료. merchantUid={}", order.getMerchantUid()); + log.info("결제 완료 처리 완료. paymentId={}", order.getPaymentId()); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java index a0220d6..468a2ab 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java @@ -1,6 +1,5 @@ package com.synapse.payment_service.service.converter.impl; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.domain.Order; @@ -12,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Component public class PartialCancelledPaymentConverter implements PaymentStatusConverter { @Override @@ -23,12 +21,12 @@ public boolean canHandle(Class paymentStatus) { @Override @Transactional public void processPayment(Order order, Payment payment) { - log.info("부분 취소 처리 시작. merchantUid={}", order.getMerchantUid()); + log.info("부분 취소 처리 시작. paymentId={}", order.getPaymentId()); // 주문 상태를 부분 취소로 업데이트 order.updateStatus(PaymentStatus.PARTIAL_CANCELLED); // 부분 취소 시에는 구독은 유지하되 크레딧 조정 등의 로직이 필요할 수 있음 - log.info("부분 취소 처리 완료. merchantUid={}", order.getMerchantUid()); + log.info("부분 취소 처리 완료. paymentId={}", order.getPaymentId()); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java index 0e01c6f..4e97c0b 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java @@ -1,11 +1,9 @@ package com.synapse.payment_service.service.converter.impl; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.domain.Order; import com.synapse.payment_service.domain.enums.PaymentStatus; -import com.synapse.payment_service.dto.PaymentData; import com.synapse.payment_service.service.converter.PaymentStatusConverter; import io.portone.sdk.server.payment.PayPendingPayment; @@ -13,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Component public class PayPendingPaymentConverter implements PaymentStatusConverter { @Override @@ -24,11 +21,11 @@ public boolean canHandle(Class paymentStatus) { @Override @Transactional public void processPayment(Order order, Payment payment) { - log.info("결제 완료 대기 처리 시작. merchantUid={}", order.getMerchantUid()); + log.info("결제 완료 대기 처리 시작. paymentId={}", order.getPaymentId()); // 주문 상태를 결제 완료 대기로 업데이트 order.updateStatus(PaymentStatus.PAY_PENDING); - log.info("결제 완료 대기 처리 완료. merchantUid={}", order.getMerchantUid()); + log.info("결제 완료 대기 처리 완료. paymentId={}", order.getPaymentId()); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java index 70e0ae7..0480742 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java @@ -1,6 +1,5 @@ package com.synapse.payment_service.service.converter.impl; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.domain.Order; @@ -12,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Component public class ReadyPaymentConverter implements PaymentStatusConverter { @Override @@ -23,11 +21,11 @@ public boolean canHandle(Class paymentStatus) { @Override @Transactional public void processPayment(Order order, Payment payment) { - log.info("결제 준비 상태 처리 시작. merchantUid={}", order.getMerchantUid()); + log.info("결제 준비 상태 처리 시작. paymentId={}", order.getPaymentId()); // 주문 상태를 준비로 업데이트 order.updateStatus(PaymentStatus.READY); - log.info("결제 준비 상태 처리 완료. merchantUid={}", order.getMerchantUid()); + log.info("결제 준비 상태 처리 완료. paymentId={}", order.getPaymentId()); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java index 15ff846..ea30948 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java @@ -1,6 +1,5 @@ package com.synapse.payment_service.service.converter.impl; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.domain.Order; @@ -12,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Component public class VirtualAccountIssuedPaymentConverter implements PaymentStatusConverter { @Override @@ -23,11 +21,11 @@ public boolean canHandle(Class paymentStatus) { @Override @Transactional public void processPayment(Order order, Payment payment) { - log.info("가상계좌 발급 완료 처리 시작. merchantUid={}", order.getMerchantUid()); + log.info("가상계좌 발급 완료 처리 시작. paymentId={}", order.getPaymentId()); // 주문 상태를 가상계좌 발급 완료로 업데이트 order.updateStatus(PaymentStatus.VIRTUAL_ACCOUNT_ISSUED); - log.info("가상계좌 발급 완료 처리 완료. merchantUid={}", order.getMerchantUid()); + log.info("가상계좌 발급 완료 처리 완료. paymentId={}", order.getPaymentId()); } -} \ No newline at end of file +} diff --git a/payment-service/src/main/resources/application-local.yml b/payment-service/src/main/resources/application-local.yml index 42a4c20..5f9b488 100644 --- a/payment-service/src/main/resources/application-local.yml +++ b/payment-service/src/main/resources/application-local.yml @@ -22,6 +22,7 @@ iamport: api-secret: ${local-iamport.api-secret} base-url: https://api.portone.io mid-key: ${local-iamport.mid-key} + webhook-secret: ${local-iamport.webhook-secret} logging: level: diff --git a/payment-service/src/test/java/com/synapse/payment_service/integrationtest/PaymentIntegrationTest.java b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/PaymentIntegrationTest.java new file mode 100644 index 0000000..39aff11 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/PaymentIntegrationTest.java @@ -0,0 +1,174 @@ +package com.synapse.payment_service.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +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.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +import com.synapse.payment_service.repository.OrderRepository; +import com.synapse.payment_service.repository.SubscriptionRepository; + +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.payment.PaymentAmount; +import io.portone.sdk.server.payment.PaymentClient; +import io.portone.sdk.server.payment.PaidPayment; +import io.portone.sdk.server.webhook.WebhookVerifier; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ActiveProfiles("test") +public class PaymentIntegrationTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private SubscriptionRepository subscriptionRepository; + @Autowired + private OrderRepository orderRepository; + + @MockitoBean + private PortOneClient portOneClient; + @MockitoBean + private WebhookVerifier webhookVerifier; + @MockitoBean + private PaymentClient paymentClient; + + private UUID memberId; + + @BeforeEach + void setUp() { + this.memberId = UUID.randomUUID(); + Subscription subscription = Subscription.builder() + .memberId(memberId) + .tier(SubscriptionTier.FREE) + .remainingChatCredits(10) + .status(SubscriptionStatus.CANCELED) + .build(); + subscriptionRepository.save(subscription); + } + + @Test + @DisplayName("결제 준비 API 성공: /api/payments/request 호출 시, PENDING 상태의 주문이 생성된다") + void requestPayment_success() throws Exception { + // given + PaymentRequestDto request = new PaymentRequestDto("PRO"); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/payments/request") + .header("X-Authenticated-Member-Id", memberId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").exists()) + .andExpect(jsonPath("$.amount").exists()); + + // DB 검증 + assertThat(orderRepository.findAll()).hasSize(1); + assertThat(orderRepository.findAll().get(0).getStatus()).isEqualTo(PaymentStatus.PENDING); + } + + @Test + @DisplayName("웹훅 처리 성공: /api/webhooks/portone 호출 시, 결제 상태가 성공적으로 업데이트된다") + void handleWebhook_success() throws Exception { + // given + // 1. 먼저 /request API를 호출하여 PENDING 상태의 주문을 생성 + PaymentRequestDto request = new PaymentRequestDto("PRO"); + + ResultActions prepareResult = mockMvc.perform(post("/api/payments/request") + .header("X-Authenticated-Member-Id", memberId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // 2. 첫 번째 API 결과에서 paymentId와 amount 추출 + String responseJson = prepareResult + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").exists()) + .andReturn() + .getResponse().getContentAsString(); + + PaymentPreparationResponse prepResponse = objectMapper.readValue(responseJson, PaymentPreparationResponse.class); + String paymentId = prepResponse.paymentId(); + BigDecimal amount = prepResponse.amount(); + String iamPortTransactionId = "imp_test_12345"; + + // 3. Mock PortOneClient가 위변조 없는 정상 데이터를 반환하도록 설정 + PaidPayment mockPaidPayment = mock(PaidPayment.class); + PaymentAmount mockAmount = mock(PaymentAmount.class); + + when(mockAmount.getTotal()).thenReturn(amount.longValue()); + when(mockPaidPayment.getAmount()).thenReturn(mockAmount); + + CompletableFuture mockFuture = CompletableFuture.completedFuture(mockPaidPayment); + given(portOneClient.getPayment()).willReturn(paymentClient); + given(paymentClient.getPayment(iamPortTransactionId)).willReturn(mockFuture); + + // 4. WebhookVerifier가 항상 검증에 성공하도록 설정 + + // 5. 포트원 웹훅 페이로드 생성 (실제 포트원 SDK 사용) + String webhookJson = """ + { + "type": "Transaction.Paid", + "data": { + "paymentId": "%s", + "transactionId": "%s" + } + } + """.formatted(paymentId, iamPortTransactionId); + + // when - 웹훅 API 호출 + ResultActions webhookResult = mockMvc.perform(post("/api/webhooks/portone") + .header(WebhookVerifier.HEADER_ID, "wh_test_id") + .header(WebhookVerifier.HEADER_SIGNATURE, "test_signature") + .header(WebhookVerifier.HEADER_TIMESTAMP, String.valueOf(Instant.now().getEpochSecond())) + .contentType(MediaType.APPLICATION_JSON) + .content(webhookJson)); + + // then + webhookResult.andExpect(status().isOk()); + + // DB 검증 - 트랜잭션이 커밋된 후 검증 + Order completedOrder = orderRepository.findByPaymentId(paymentId).get(); + assertThat(completedOrder.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(completedOrder.getSubscription().getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + + Subscription updatedSubscription = subscriptionRepository.findByMemberId(memberId).get(); + assertThat(updatedSubscription.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(updatedSubscription.getTier()).isEqualTo(SubscriptionTier.PRO); + } +} diff --git a/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java index 03eeec9..347a1d1 100644 --- a/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java +++ b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java @@ -15,11 +15,13 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.controller.test.InternalApiController; +import io.portone.sdk.server.PortOneClient; @SpringBootTest @AutoConfigureMockMvc @@ -30,6 +32,9 @@ public class ResourceServerIntegrationTest { @Autowired private MockMvc mockMvc; + + @MockitoBean + private PortOneClient portOneClient; @Test @DisplayName("리소스 접근 성공: 유효한 JWT와 올바른 scope으로 보호된 API 호출 시 200 OK를 응답한다") diff --git a/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java b/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java new file mode 100644 index 0000000..0b7db65 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java @@ -0,0 +1,179 @@ +package com.synapse.payment_service.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.request.PaymentVerificationRequest; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +import com.synapse.payment_service.repository.OrderRepository; +import com.synapse.payment_service.repository.SubscriptionRepository; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; +import com.synapse.payment_service.service.converter.DelegatingPaymentStatusConverter; + +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.payment.PaymentAmount; +import io.portone.sdk.server.payment.PaymentClient; +import io.portone.sdk.server.payment.PaidPayment; + +@ExtendWith(MockitoExtension.class) +public class PaymentServiceTest { + @InjectMocks + private PaymentService paymentService; + + @Mock + private PortOneClient portOneClient; + @Mock + private PaymentClient paymentClient; + @Mock + private OrderRepository orderRepository; + @Mock + private SubscriptionRepository subscriptionRepository; + @Mock + private PaymentStatusConverter paymentStatusConverter; + @Mock + private ObjectMapper objectMapper; + + private UUID memberId; + private String paymentId; + private String iamPortTransactionId; + + @BeforeEach + void setUp() { + memberId = UUID.randomUUID(); + paymentId = "order_" + UUID.randomUUID(); + iamPortTransactionId = "imp_" + UUID.randomUUID(); + } + + @Test + @DisplayName("결제 사전 준비 성공: PENDING 상태의 주문이 생성되고, 프론트에 필요한 정보가 반환된다") + void preparePayment_success() { + // given + PaymentRequestDto request = new PaymentRequestDto("PRO"); + String orderName = "pro_subscription"; + Subscription mockSubscription = Subscription.builder().memberId(memberId).tier(SubscriptionTier.FREE).build(); + + given(subscriptionRepository.findByMemberId(memberId)).willReturn(Optional.of(mockSubscription)); + given(orderRepository.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + PaymentPreparationResponse response = paymentService.preparePayment(memberId, request); + + // then + assertEquals(response.orderName(), orderName); + assertEquals(response.amount(), new BigDecimal("100000")); + verify(orderRepository).save(argThat(order -> order.getStatus() == PaymentStatus.PENDING)); + assertThat(mockSubscription.getTier()).isEqualTo(SubscriptionTier.FREE); + } + + @Test + @DisplayName("결제 검증 성공: 위변조가 없는 결제 건에 대해 구독 상태를 성공적으로 업데이트한다") + void verifyAndProcess_success() { + Subscription mockSubscription = Subscription.builder().memberId(memberId).build(); + // given + Order pendingOrder = Order.builder() + .paymentId(paymentId) + .amount(new BigDecimal("100000")) + .status(PaymentStatus.PENDING) + .subscription(mockSubscription) + .build(); + + // PortOne API의 응답을 모의 처리 + Payment.Recognized mockApiResponse = mock(Payment.Recognized.class); + PaymentAmount mockAmount = mock(PaymentAmount.class); + + when(mockAmount.getTotal()).thenReturn(100000L); + when(mockApiResponse.getAmount()).thenReturn(mockAmount); + + given(orderRepository.findByPaymentId(paymentId)).willReturn(Optional.of(pendingOrder)); + given(portOneClient.getPayment()).willReturn(paymentClient); + + // CompletableFuture Mock 설정 + CompletableFuture mockFuture = CompletableFuture.completedFuture(mockApiResponse); + given(paymentClient.getPayment(iamPortTransactionId)).willReturn(mockFuture); + + doAnswer(invocation -> { + Order order = invocation.getArgument(0); + order.updateStatus(PaymentStatus.PAID); + order.getSubscription().activate(SubscriptionTier.PRO); + return null; + }).when(paymentStatusConverter).processPayment(any(Order.class), any(Payment.class)); + + // when + paymentService.verifyAndProcess(new PaymentVerificationRequest(paymentId, iamPortTransactionId)); + + // then + assertThat(pendingOrder.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(pendingOrder.getIamPortTransactionId()).isEqualTo(iamPortTransactionId); + assertThat(pendingOrder.getSubscription().getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(pendingOrder.getSubscription().getTier()).isEqualTo(SubscriptionTier.PRO); + } + + @Test + @DisplayName("실제 DelegatingPaymentStatusConverter를 사용한 결제 검증 테스트") + void verifyAndProcess_withRealDelegatingConverter() { + // given + Subscription mockSubscription = Subscription.builder().memberId(memberId).build(); + Order pendingOrder = Order.builder() + .paymentId(paymentId) + .amount(new BigDecimal("100000")) + .status(PaymentStatus.PENDING) + .subscription(mockSubscription) + .build(); + + // 실제 DelegatingPaymentStatusConverter 사용 (내부에 PaidPaymentConverter 포함) + PaymentStatusConverter realDelegatingConverter = new DelegatingPaymentStatusConverter(); + PaymentService paymentServiceWithRealConverter = new PaymentService( + subscriptionRepository, orderRepository, portOneClient, + realDelegatingConverter, objectMapper); + + // PaidPayment 타입으로 모킹 (실제 결제 완료 상태) + PaidPayment mockPaidPayment = mock(PaidPayment.class); + PaymentAmount mockAmount = mock(PaymentAmount.class); + + when(mockAmount.getTotal()).thenReturn(100000L); + when(mockPaidPayment.getAmount()).thenReturn(mockAmount); + + given(orderRepository.findByPaymentId(paymentId)).willReturn(Optional.of(pendingOrder)); + given(portOneClient.getPayment()).willReturn(paymentClient); + + // CompletableFuture Mock 설정 여기서는 .join()으로 테스트 불가능 -> .join 메서드는 런타임시에 동기화를 진행하는데 테스트중에 null이 들어가버린다. + CompletableFuture mockFuture = CompletableFuture.completedFuture(mockPaidPayment); + given(paymentClient.getPayment(iamPortTransactionId)).willReturn(mockFuture); + + // when + paymentServiceWithRealConverter.verifyAndProcess( + new PaymentVerificationRequest(paymentId, iamPortTransactionId)); + + // then - 실제 PaidPaymentConverter 로직에 의한 상태 변경 검증 + assertThat(pendingOrder.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(pendingOrder.getIamPortTransactionId()).isEqualTo(iamPortTransactionId); + assertThat(pendingOrder.getSubscription().getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(pendingOrder.getSubscription().getTier()).isEqualTo(SubscriptionTier.PRO); + } +} diff --git a/payment-service/src/test/resources/application-test.yml b/payment-service/src/test/resources/application-test.yml index c2bf88b..3e5f6ab 100644 --- a/payment-service/src/test/resources/application-test.yml +++ b/payment-service/src/test/resources/application-test.yml @@ -41,6 +41,12 @@ spring: open-in-view: false show-sql: true +iamport: + api-secret: dGVzdC1hcGktc2VjcmV0 + base-url: https://api.portone.io + mid-key: test-mid-key + webhook-secret: dGVzdC13ZWJob29rLXNlY3JldA== + logging: level: org: