From b8767e49ef65c10d2bb530a168cd09d52d0ee886 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 24 Feb 2026 17:12:08 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20payment=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=9D=98=20global=20=EC=84=A4=EC=A0=95=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/aop/TosspaymentsLoggingAspect.java | 32 +++++++++++++++ .../kokomen/global/config/RetryConfig.java | 39 +++++++++++++++++++ .../TosspaymentsConfirmRetryPolicy.java | 36 +++++++++++++++++ .../global/exception/BadRequestException.java | 4 ++ .../exception/GlobalExceptionHandler.java | 9 +++++ .../InternalServerErrorException.java | 14 +++++++ .../global/exception/NotFoundException.java | 10 +++++ .../exception/PaymentServiceErrorMessage.java | 25 ++++++++++++ .../ObjectToStringDeserializer.java | 25 ++++++++++++ src/main/resources/application-dev.yml | 8 ++++ src/main/resources/application-load-test.yml | 8 ++++ src/main/resources/application-local.yml | 8 ++++ src/main/resources/application-prod.yml | 8 ++++ src/test/resources/application.yml | 8 ++++ 14 files changed, 234 insertions(+) create mode 100644 src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java create mode 100644 src/main/java/com/samhap/kokomen/global/config/RetryConfig.java create mode 100644 src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java create mode 100644 src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java create mode 100644 src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java create mode 100644 src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java create mode 100644 src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java diff --git a/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java b/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java new file mode 100644 index 00000000..88b02e43 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java @@ -0,0 +1,32 @@ +package com.samhap.kokomen.global.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; + +@Slf4j +@Order(1) +@Aspect +@Component +public class TosspaymentsLoggingAspect { + + @Around("execution(* com.samhap.kokomen.payment.external.TosspaymentsClient.*(..))") + public Object logTosspaymentsApiCall(ProceedingJoinPoint joinPoint) throws Throwable { + String methodName = joinPoint.getSignature().getName(); + Object[] args = joinPoint.getArgs(); + + log.info("[토스페이먼츠 API 요청] {} - args: {}", methodName, args); + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + Object result = joinPoint.proceed(); + stopWatch.stop(); + log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}", + methodName, stopWatch.getTotalTimeMillis(), result); + return result; + } +} diff --git a/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java b/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java new file mode 100644 index 00000000..ecf52757 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java @@ -0,0 +1,39 @@ +package com.samhap.kokomen.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; + +@Configuration +public class RetryConfig { + + @Value("${retry.tosspayments.max-attempts}") + private int maxAttempts; + + @Value("${retry.tosspayments.initial-interval}") + private long initialInterval; + + @Value("${retry.tosspayments.multiplier}") + private double multiplier; + + @Value("${retry.tosspayments.max-interval}") + private long maxInterval; + + @Bean + public RetryTemplate tosspaymentsConfirmRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + TosspaymentsConfirmRetryPolicy retryPolicy = new TosspaymentsConfirmRetryPolicy(maxAttempts); + retryTemplate.setRetryPolicy(retryPolicy); + + ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); + backOffPolicy.setInitialInterval(initialInterval); + backOffPolicy.setMultiplier(multiplier); + backOffPolicy.setMaxInterval(maxInterval); + retryTemplate.setBackOffPolicy(backOffPolicy); + + return retryTemplate; + } +} diff --git a/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java b/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java new file mode 100644 index 00000000..9cd50833 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java @@ -0,0 +1,36 @@ +package com.samhap.kokomen.global.config; + +import org.springframework.retry.RetryContext; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +public class TosspaymentsConfirmRetryPolicy extends SimpleRetryPolicy { + + public TosspaymentsConfirmRetryPolicy(int maxAttempts) { + super(maxAttempts); + } + + @Override + public boolean canRetry(RetryContext context) { + Throwable lastException = context.getLastThrowable(); + if (lastException != null && !isRetryableException(lastException)) { + return false; + } + return super.canRetry(context); + } + + private boolean isRetryableException(Throwable throwable) { + if (throwable instanceof HttpServerErrorException) { + return true; + } + if (throwable instanceof ResourceAccessException) { + return true; + } + if (throwable instanceof HttpClientErrorException e) { + return e.getStatusCode().value() == 409; + } + return false; + } +} diff --git a/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java b/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java index 40beba6c..f81dba68 100644 --- a/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java +++ b/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java @@ -7,4 +7,8 @@ public class BadRequestException extends KokomenException { public BadRequestException(String message) { super(message, HttpStatus.BAD_REQUEST); } + + public BadRequestException(String message, Throwable cause) { + super(message, cause, HttpStatus.BAD_REQUEST); + } } diff --git a/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index bdb378bc..01509d0f 100644 --- a/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -93,6 +94,14 @@ public ResponseEntity handleNoResourceFound(NoResourceFoundException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleHttpMediaTypeNotSupportedException( + HttpMediaTypeNotSupportedException e) { + log.warn("HttpMediaTypeNotSupportedException :: message: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("지원하지 않는 Content-Type입니다.")); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, diff --git a/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java b/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java new file mode 100644 index 00000000..08a2af85 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen.global.exception; + +import org.springframework.http.HttpStatus; + +public class InternalServerErrorException extends KokomenException { + + public InternalServerErrorException(String message) { + super(message, HttpStatus.INTERNAL_SERVER_ERROR); + } + + public InternalServerErrorException(String message, Throwable cause) { + super(message, cause, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java b/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java new file mode 100644 index 00000000..4c3f6131 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.global.exception; + +import org.springframework.http.HttpStatus; + +public class NotFoundException extends KokomenException { + + public NotFoundException(String message) { + super(message, HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java b/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java new file mode 100644 index 00000000..9f66fe08 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java @@ -0,0 +1,25 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum PaymentServiceErrorMessage { + + MISSING_REQUEST_PARAMETER("필수 요청 파라미터가 누락되었습니다."), + JSON_PARSE_ERROR("JSON 파싱 오류: 유효하지 않은 값이 전달되었습니다."), + PAYMENT_NOT_FOUND_BY_ID("해당 id의 결제 정보가 존재하지 않습니다."), + PAYMENT_NOT_FOUND_BY_PAYMENT_KEY("해당 paymentKey의 결제 정보가 존재하지 않습니다."), + PAYMENT_RESULT_NOT_FOUND("해당 결제의 결과 정보가 존재하지 않습니다."), + CONFIRM_SERVER_ERROR("결제 처리 중 서버 오류가 발생했습니다."), + CANCEL_SERVER_ERROR("결제 취소 처리 중 서버 오류가 발생했습니다."), + CANCEL_NETWORK_ERROR("결제 취소 처리 중 네트워크 오류가 발생했습니다."), + INVALID_REQUEST("잘못된 요청입니다."), + INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다."), + INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다."); + + private final String message; + + PaymentServiceErrorMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java b/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java new file mode 100644 index 00000000..38fa8786 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java @@ -0,0 +1,25 @@ +package com.samhap.kokomen.global.infrastructure; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; + +public class ObjectToStringDeserializer extends JsonDeserializer { + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + + // 이미 문자열인 경우 그대로 반환 + if (node.isTextual()) { + return node.asText(); + } + + // 객체나 배열인 경우 JSON 문자열로 변환 + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(node); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 70ccf458..16a9ec94 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -24,3 +24,11 @@ aws: resume-s3-path: dev/resume/ portfolio-s3-path: dev/portfolio/ company-s3-path: recruit/dev/company/ +tosspayments: + widget-secret-key: ${WIDGET_SECRET_KEY_DEV} +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/main/resources/application-load-test.yml b/src/main/resources/application-load-test.yml index cf590498..1d7af6f9 100644 --- a/src/main/resources/application-load-test.yml +++ b/src/main/resources/application-load-test.yml @@ -24,3 +24,11 @@ aws: resume-s3-path: dev/resume/ portfolio-s3-path: dev/portfolio/ company-s3-path: recruit/dev/company/ +tosspayments: + widget-secret-key: ${WIDGET_SECRET_KEY_PROD} +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c6e53d52..a3519c3e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,3 +23,11 @@ aws: resume-s3-path: local/resume/ portfolio-s3-path: local/portfolio/ company-s3-path: recruit/local/company/ +tosspayments: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4c2b5837..3f600e86 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -24,3 +24,11 @@ aws: resume-s3-path: prod/resume/ portfolio-s3-path: prod/portfolio/ company-s3-path: recruit/prod/company/ +tosspayments: + widget-secret-key: ${WIDGET_SECRET_KEY_PROD} +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index b4d982df..aeca4662 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -26,6 +26,14 @@ payment: base-url: http://kokomen-payment-test connect-timeout: 3s read-timeout: 3s +tosspayments: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 supertone: api-token: mock-token From b561196e4d80c3b0dda0d2ae6423f874c473e9f6 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 24 Feb 2026 17:20:58 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20sql=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...39__create_tosspayments_payment_tables.sql | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/main/resources/db/migration/V39__create_tosspayments_payment_tables.sql diff --git a/src/main/resources/db/migration/V39__create_tosspayments_payment_tables.sql b/src/main/resources/db/migration/V39__create_tosspayments_payment_tables.sql new file mode 100644 index 00000000..ffd64bc9 --- /dev/null +++ b/src/main/resources/db/migration/V39__create_tosspayments_payment_tables.sql @@ -0,0 +1,55 @@ +CREATE TABLE tosspayments_payment ( + id BIGINT NOT NULL AUTO_INCREMENT, + payment_key VARCHAR(200) NOT NULL, + member_id BIGINT NOT NULL, + order_id VARCHAR(100) NOT NULL, + order_name VARCHAR(200) NOT NULL, + total_amount BIGINT NOT NULL, + metadata JSON NOT NULL, + state ENUM('NEED_APPROVE', 'APPROVED', 'NOT_NEED_CANCEL', 'NEED_CANCEL', + 'CONNECTION_TIMEOUT', 'CANCELED', 'CLIENT_BAD_REQUEST', + 'SERVER_BAD_REQUEST', 'COMPLETED') NOT NULL, + service_type ENUM('INTERVIEW') NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_payment_payment_key (payment_key), + UNIQUE KEY uk_payment_order_id (order_id), + KEY idx_payment_member_id (member_id) +); + +CREATE TABLE tosspayments_payment_result ( + id BIGINT NOT NULL AUTO_INCREMENT, + tosspayments_payment_id BIGINT NOT NULL, + type ENUM('NORMAL', 'BILLING', 'BRANDPAY') NOT NULL, + m_id VARCHAR(200) NOT NULL, + currency VARCHAR(100) NOT NULL, + method VARCHAR(100), + total_amount BIGINT NOT NULL, + balance_amount BIGINT NOT NULL, + tosspayments_status ENUM('READY', 'IN_PROGRESS', 'WAITING_FOR_DEPOSIT', 'DONE', + 'CANCELED', 'PARTIAL_CANCELED', 'ABORTED', 'EXPIRED') NOT NULL, + requested_at DATETIME(6) NOT NULL, + approved_at DATETIME(6), + last_transaction_key VARCHAR(200), + supplied_amount BIGINT, + vat BIGINT, + tax_free_amount BIGINT, + tax_exemption_amount BIGINT, + is_partial_cancelable BOOLEAN NOT NULL DEFAULT FALSE, + receipt_url VARCHAR(500), + easy_pay_provider VARCHAR(50), + easy_pay_amount BIGINT, + easy_pay_discount_amount BIGINT, + country VARCHAR(10), + failure_code VARCHAR(100), + failure_message TEXT, + cancel_reason VARCHAR(200) DEFAULT NULL, + canceled_at DATETIME DEFAULT NULL, + cancel_status VARCHAR(50) DEFAULT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_payment_result_payment + FOREIGN KEY (tosspayments_payment_id) REFERENCES tosspayments_payment(id) +); From b32c485038b8fe5028c2e9f3b45d5e89b5c8903e Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 24 Feb 2026 17:40:10 +0900 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/domain/PaymentErrorMessage.java | 17 ++ .../kokomen/payment/domain/PaymentState.java | 14 ++ .../kokomen/payment/domain/PaymentType.java | 7 + .../kokomen/payment/domain/ServiceType.java | 6 + .../payment/domain/TosspaymentsPayment.java | 94 ++++++++++ .../domain/TosspaymentsPaymentResult.java | 153 ++++++++++++++++ .../payment/domain/TosspaymentsStatus.java | 12 ++ .../external/TossPaymentsClientBuilder.java | 52 ++++++ .../payment/external/TosspaymentsClient.java | 37 ++++ .../TosspaymentsInternalServerErrorCode.java | 22 +++ .../payment/external/dto/Checkout.java | 6 + .../kokomen/payment/external/dto/EasyPay.java | 8 + .../kokomen/payment/external/dto/Failure.java | 7 + .../kokomen/payment/external/dto/Receipt.java | 6 + .../dto/TossDateTimeDeserializer.java | 19 ++ .../external/dto/TosspaymentsCancel.java | 20 +++ .../dto/TosspaymentsConfirmRequest.java | 8 + .../dto/TosspaymentsPaymentCancelRequest.java | 6 + .../dto/TosspaymentsPaymentResponse.java | 69 ++++++++ .../TosspaymentsPaymentRepository.java | 10 ++ .../TosspaymentsPaymentResultRepository.java | 10 ++ .../payment/service/PaymentFacadeService.java | 167 ++++++++++++++++++ .../TosspaymentsPaymentResultService.java | 32 ++++ .../service/TosspaymentsPaymentService.java | 50 ++++++ .../TosspaymentsTransactionService.java | 46 +++++ .../payment/service/dto/CancelRequest.java | 15 ++ .../kokomen/payment/service/dto/Checkout.java | 10 ++ .../payment/service/dto/ConfirmRequest.java | 36 ++++ .../kokomen/payment/service/dto/EasyPay.java | 16 ++ .../kokomen/payment/service/dto/Failure.java | 11 ++ .../payment/service/dto/PaymentResponse.java | 74 ++++++++ .../kokomen/payment/service/dto/Receipt.java | 10 ++ .../service/dto/TosspaymentsCancel.java | 37 ++++ 33 files changed, 1087 insertions(+) create mode 100644 src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java create mode 100644 src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java create mode 100644 src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java create mode 100644 src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java create mode 100644 src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java create mode 100644 src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java create mode 100644 src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java create mode 100644 src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java create mode 100644 src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java create mode 100644 src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java create mode 100644 src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java diff --git a/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java b/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java new file mode 100644 index 00000000..acaf60a1 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java @@ -0,0 +1,17 @@ +package com.samhap.kokomen.payment.domain; + +import lombok.Getter; + +@Getter +public enum PaymentErrorMessage { + + PAYMENT_KEY_MISMATCH("토스 페이먼츠 응답의 paymentKey가 DB에 저장된 값과 다릅니다."), + ORDER_ID_MISMATCH("토스 페이먼츠 응답의 orderId가 DB에 저장된 값과 다릅니다."), + TOTAL_AMOUNT_MISMATCH("토스 페이먼츠 응답의 totalAmount가 DB에 저장된 값과 다릅니다."); + + private final String message; + + PaymentErrorMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java b/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java new file mode 100644 index 00000000..65662fd7 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen.payment.domain; + +public enum PaymentState { + NEED_APPROVE, // 결제 승인 대기 상태가 오래 지속되는 경우 결제 취소 필요 + APPROVED, // 결제 완료 후 비즈니스 반영이 안된 상태 + NOT_NEED_CANCEL, // NEED_CANCEL 상태에서 환불처리 시도했으나 애초에 결제가 안 된 것으로 확인된 경우 + NEED_CANCEL, // 리드 타임 아웃 or 토스페이먼츠 5xx 응답인 경우 결제 취소 필요 + CONNECTION_TIMEOUT, // 연결 타임 아웃인 경우에는 환불 처리 불필요 + CANCELED, // 환불 처리 완료 + CLIENT_BAD_REQUEST, // 클라이언트 문제로 토스페이먼츠로부터 400을 받은 경우 사용자에게 메시지 노출 필요 + SERVER_BAD_REQUEST, // 서버 문제로 토스페이먼츠로부터 400을 받은 경우 사용자에게 메시지 노출 불필요 + COMPLETED, // 결제 완료 후 비즈니스 반영도 완료된 상태 + ; +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java b/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java new file mode 100644 index 00000000..56fb5e00 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java @@ -0,0 +1,7 @@ +package com.samhap.kokomen.payment.domain; + +public enum PaymentType { + NORMAL, + BILLING, + BRANDPAY +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java b/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java new file mode 100644 index 00000000..94030eb4 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.payment.domain; + +public enum ServiceType { + INTERVIEW, + ; +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java new file mode 100644 index 00000000..6cb7ccf5 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -0,0 +1,94 @@ +package com.samhap.kokomen.payment.domain; + +import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.annotation.LastModifiedDate; + +@Slf4j +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "tosspayments_payment", indexes = { + @Index(name = "idx_payment_member_id", columnList = "member_id") +}) +public class TosspaymentsPayment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "payment_key", nullable = false, unique = true) + private String paymentKey; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "order_id", nullable = false, unique = true) + private String orderId; + + @Column(name = "order_name", nullable = false) + private String orderName; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "metadata", columnDefinition = "json", nullable = false) + private String metadata; + + @Column(name = "state", nullable = false) + @Enumerated(EnumType.STRING) + private PaymentState state; + + @Column(name = "service_type", nullable = false) + @Enumerated(EnumType.STRING) + private ServiceType serviceType; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, String orderName, Long totalAmount, + String metadata, ServiceType serviceType) { + this.paymentKey = paymentKey; + this.memberId = memberId; + this.orderId = orderId; + this.orderName = orderName; + this.totalAmount = totalAmount; + this.metadata = metadata; + this.serviceType = serviceType; + this.state = PaymentState.NEED_APPROVE; + } + + public void updateState(PaymentState state) { + this.state = state; + } + + public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount) { + if (!this.paymentKey.equals(paymentKey)) { + log.error("paymentKey 불일치 - 응답: {}, DB: {}", paymentKey, this.paymentKey); + throw new InternalServerErrorException(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); + } + if (!this.orderId.equals(orderId)) { + log.error("orderId 불일치 - 응답: {}, DB: {}", orderId, this.orderId); + throw new InternalServerErrorException(PaymentErrorMessage.ORDER_ID_MISMATCH.getMessage()); + } + if (!this.totalAmount.equals(totalAmount)) { + log.error("totalAmount 불일치 - 응답: {}, DB: {}", totalAmount, this.totalAmount); + throw new InternalServerErrorException(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); + } + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java new file mode 100644 index 00000000..699729a7 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java @@ -0,0 +1,153 @@ +package com.samhap.kokomen.payment.domain; + +import com.samhap.kokomen.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class TosspaymentsPaymentResult extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tosspayments_payment_id", nullable = false) + private TosspaymentsPayment tosspaymentsPayment; + + @Column(name = "type", nullable = false) + @Enumerated(EnumType.STRING) + private PaymentType type; + + @Column(name = "m_id", nullable = false) + private String mId; + + @Column(name = "currency", nullable = false) + private String currency; + + @Column(name = "method") + private String method; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "balance_amount", nullable = false) + private Long balanceAmount; + + @Column(name = "tosspayments_status", nullable = false) + @Enumerated(EnumType.STRING) + private TosspaymentsStatus tosspaymentsStatus; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "last_transaction_key") + private String lastTransactionKey; + + @Column(name = "supplied_amount") + private Long suppliedAmount; + + @Column(name = "vat") + private Long vat; + + @Column(name = "tax_free_amount") + private Long taxFreeAmount; + + @Column(name = "tax_exemption_amount") + private Long taxExemptionAmount; + + @Column(name = "is_partial_cancelable", nullable = false) + private boolean isPartialCancelable; + + @Column(name = "receipt_url") + private String receiptUrl; + + @Column(name = "easy_pay_provider") + private String easyPayProvider; + + @Column(name = "easy_pay_amount") + private Long easyPayAmount; + + @Column(name = "easy_pay_discount_amount") + private Long easyPayDiscountAmount; + + @Column(name = "country") + private String country; + + @Column(name = "failure_code") + private String failureCode; + + @Column(name = "failure_message") + private String failureMessage; + + @Column(name = "cancel_reason") + private String cancelReason; + + @Column(name = "canceled_at") + private LocalDateTime canceledAt; + + @Column(name = "cancel_status") + private String cancelStatus; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + public TosspaymentsPaymentResult(TosspaymentsPayment tosspaymentsPayment, PaymentType type, String mId, String currency, Long totalAmount, String method, + Long balanceAmount, TosspaymentsStatus tosspaymentsStatus, LocalDateTime requestedAt, LocalDateTime approvedAt, + String lastTransactionKey, Long suppliedAmount, Long vat, Long taxFreeAmount, Long taxExemptionAmount, + boolean isPartialCancelable, String receiptUrl, String easyPayProvider, Long easyPayAmount, + Long easyPayDiscountAmount, String country, String failureCode, String failureMessage) { + this.tosspaymentsPayment = tosspaymentsPayment; + this.type = type; + this.mId = mId; + this.currency = currency; + this.totalAmount = totalAmount; + this.method = method; + this.balanceAmount = balanceAmount; + this.tosspaymentsStatus = tosspaymentsStatus; + this.requestedAt = requestedAt; + this.approvedAt = approvedAt; + this.lastTransactionKey = lastTransactionKey; + this.suppliedAmount = suppliedAmount; + this.vat = vat; + this.taxFreeAmount = taxFreeAmount; + this.taxExemptionAmount = taxExemptionAmount; + this.isPartialCancelable = isPartialCancelable; + this.receiptUrl = receiptUrl; + this.easyPayProvider = easyPayProvider; + this.easyPayAmount = easyPayAmount; + this.easyPayDiscountAmount = easyPayDiscountAmount; + this.country = country; + this.failureCode = failureCode; + this.failureMessage = failureMessage; + } + + public void updateCancelInfo(String cancelReason, LocalDateTime canceledAt, Long easyPayDiscountAmount, + String lastTransactionKey, String cancelStatus, TosspaymentsStatus tosspaymentsStatus) { + this.cancelReason = cancelReason; + this.canceledAt = canceledAt; + this.easyPayDiscountAmount = easyPayDiscountAmount; + this.lastTransactionKey = lastTransactionKey; + this.cancelStatus = cancelStatus; + this.tosspaymentsStatus = tosspaymentsStatus; + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java new file mode 100644 index 00000000..b5982142 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java @@ -0,0 +1,12 @@ +package com.samhap.kokomen.payment.domain; + +public enum TosspaymentsStatus { + READY, + IN_PROGRESS, + WAITING_FOR_DEPOSIT, + DONE, + CANCELED, + PARTIAL_CANCELED, + ABORTED, + EXPIRED +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java b/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java new file mode 100644 index 00000000..c4b05ab5 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java @@ -0,0 +1,52 @@ +package com.samhap.kokomen.payment.external; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.Base64; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Getter +@Component +public class TossPaymentsClientBuilder { + + private static final String TOSSPAYMENTS_API_URL = "https://api.tosspayments.com"; + + private final RestClient.Builder tossPaymentsClientBuilder; + + public TossPaymentsClientBuilder( + RestClient.Builder builder, + @Value("${tosspayments.widget-secret-key}") String tossPaymentsWidgetSecretKey) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(3000); + requestFactory.setReadTimeout(15000); + + String encodedSecretKey = Base64.getEncoder().encodeToString((tossPaymentsWidgetSecretKey + ":").getBytes()); + + this.tossPaymentsClientBuilder = builder + .requestFactory(requestFactory) + .baseUrl(TOSSPAYMENTS_API_URL) + .defaultHeader("Authorization", "Basic " + encodedSecretKey) + .defaultHeader("Content-Type", "application/json") + .messageConverters(converters -> { + converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); + converters.add(new MappingJackson2HttpMessageConverter(createObjectMapper())); + }); + } + + private ObjectMapper createObjectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE); + return objectMapper; + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java new file mode 100644 index 00000000..6640d329 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java @@ -0,0 +1,37 @@ +package com.samhap.kokomen.payment.external; + +import com.samhap.kokomen.payment.external.dto.TosspaymentsConfirmRequest; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentCancelRequest; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +public class TosspaymentsClient { + + private final RestClient restClient; + + public TosspaymentsClient(TossPaymentsClientBuilder tossPaymentsClientBuilder) { + this.restClient = tossPaymentsClientBuilder.getTossPaymentsClientBuilder().build(); + } + + public TosspaymentsPaymentResponse confirmPayment(TosspaymentsConfirmRequest request, String idempotencyKey) { + return restClient.post() + .uri("/v1/payments/confirm") + .header("Idempotency-Key", idempotencyKey) + .body(request) + .retrieve() + .body(TosspaymentsPaymentResponse.class); + } + + public TosspaymentsPaymentResponse cancelPayment( + String paymentKey, + TosspaymentsPaymentCancelRequest request + ) { + return restClient.post() + .uri("/v1/payments/{paymentKey}/cancel", paymentKey) + .body(request) + .retrieve() + .body(TosspaymentsPaymentResponse.class); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java new file mode 100644 index 00000000..01c39dca --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java @@ -0,0 +1,22 @@ +package com.samhap.kokomen.payment.external; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +public enum TosspaymentsInternalServerErrorCode { + + INVALID_API_KEY, + INVALID_AUTHORIZE_AUTH, + UNAUTHORIZED_KEY, + INCORRECT_BASIC_AUTH_FORMAT, + ; + + private static final Set CODES = Arrays.stream(values()) + .map(TosspaymentsInternalServerErrorCode::name) + .collect(Collectors.toSet()); + + public static boolean contains(String code) { + return CODES.contains(code); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java b/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java new file mode 100644 index 00000000..e4374f2e --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.payment.external.dto; + +public record Checkout( + String url +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java b/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java new file mode 100644 index 00000000..e441b2df --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.payment.external.dto; + +public record EasyPay( + String provider, + Long amount, + Long discountAmount +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java b/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java new file mode 100644 index 00000000..d20fc8ae --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java @@ -0,0 +1,7 @@ +package com.samhap.kokomen.payment.external.dto; + +public record Failure( + String code, + String message +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java b/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java new file mode 100644 index 00000000..9637829f --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.payment.external.dto; + +public record Receipt( + String url +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java new file mode 100644 index 00000000..e2f6af8f --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java @@ -0,0 +1,19 @@ +package com.samhap.kokomen.payment.external.dto; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +public class TossDateTimeDeserializer extends JsonDeserializer { + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String dateTimeStr = p.getText(); + OffsetDateTime offsetDateTime = OffsetDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + return offsetDateTime.toLocalDateTime(); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java new file mode 100644 index 00000000..9ae115f7 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java @@ -0,0 +1,20 @@ +package com.samhap.kokomen.payment.external.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.time.LocalDateTime; + +public record TosspaymentsCancel( + String transactionKey, + String cancelReason, + Long taxExemptionAmount, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime canceledAt, + Long easyPayDiscountAmount, + String receiptKey, + Long cancelAmount, + Long taxFreeAmount, + Long refundableAmount, + String cancelStatus, + String cancelRequestId +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java new file mode 100644 index 00000000..5740f87d --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.payment.external.dto; + +public record TosspaymentsConfirmRequest( + String paymentKey, + String orderId, + Long amount +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java new file mode 100644 index 00000000..69f3e8f8 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.payment.external.dto; + +public record TosspaymentsPaymentCancelRequest( + String cancelReason +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java new file mode 100644 index 00000000..2d0fd87e --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java @@ -0,0 +1,69 @@ +package com.samhap.kokomen.payment.external.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.global.infrastructure.ObjectToStringDeserializer; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import java.time.LocalDateTime; + +public record TosspaymentsPaymentResponse( + String paymentKey, + PaymentType type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Long totalAmount, + Long balanceAmount, + TosspaymentsStatus status, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime requestedAt, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime approvedAt, + String lastTransactionKey, + Long suppliedAmount, + Long vat, + Long taxFreeAmount, + Long taxExemptionAmount, + boolean isPartialCancelable, + @JsonDeserialize(using = ObjectToStringDeserializer.class) + String metadata, + Receipt receipt, + Checkout checkout, + EasyPay easyPay, + String country, + Failure failure, + java.util.List cancels +) { + + public TosspaymentsPaymentResult toTosspaymentsPaymentResult(TosspaymentsPayment tosspaymentsPayment) { + return new TosspaymentsPaymentResult( + tosspaymentsPayment, + this.type, + this.mId, + this.currency, + this.totalAmount, + this.method, + this.balanceAmount, + this.status, + this.requestedAt, + this.approvedAt, + this.lastTransactionKey, + this.suppliedAmount, + this.vat, + this.taxFreeAmount, + this.taxExemptionAmount, + this.isPartialCancelable, + this.receipt() != null ? this.receipt().url() : null, + this.easyPay() != null ? this.easyPay().provider() : null, + this.easyPay() != null ? this.easyPay().amount() : null, + this.easyPay() != null ? this.easyPay().discountAmount() : null, + this.country, + this.failure() != null ? this.failure().code() : null, + this.failure() != null ? this.failure().message() : null + ); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java new file mode 100644 index 00000000..89e8fc13 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.repository; + +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TosspaymentsPaymentRepository extends JpaRepository { + + Optional findByPaymentKey(String paymentKey); +} diff --git a/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java new file mode 100644 index 00000000..83110d43 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.repository; + +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TosspaymentsPaymentResultRepository extends JpaRepository { + + Optional findByTosspaymentsPaymentId(Long tosspaymentsPaymentId); +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java new file mode 100644 index 00000000..b59a17e5 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -0,0 +1,167 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.global.exception.KokomenException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.external.TosspaymentsClient; +import com.samhap.kokomen.payment.external.TosspaymentsInternalServerErrorCode; +import com.samhap.kokomen.payment.external.dto.Failure; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentCancelRequest; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.service.dto.CancelRequest; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import java.net.SocketTimeoutException; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +@Slf4j +@RequiredArgsConstructor +@Service +public class PaymentFacadeService { + + private final TosspaymentsTransactionService tosspaymentsTransactionService; + private final TosspaymentsPaymentService tosspaymentsPaymentService; + private final TosspaymentsClient tosspaymentsClient; + private final RetryTemplate tosspaymentsConfirmRetryTemplate; + + public PaymentResponse confirmPayment(ConfirmRequest request) { + TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request); + try { + TosspaymentsPaymentResponse tosspaymentsPaymentResponse = confirmPayment(request, tosspaymentsPayment); + return PaymentResponse.from(tosspaymentsPaymentResponse); + } catch (KokomenException | HttpServerErrorException | ResourceAccessException e) { + // inner에서 상태 처리 완료 + throw e; + } catch (Exception e) { + // 예상치 못한 예외만 NEED_CANCEL 설정 + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + throw e; + } + } + + private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, + TosspaymentsPayment tosspaymentsPayment) { + String idempotencyKey = UUID.randomUUID().toString(); + try { + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsConfirmRetryTemplate.execute( + context -> { + if (context.getRetryCount() > 0) { + log.warn("토스페이먼츠 결제 승인 재시도 {}회차, paymentKey = {}", + context.getRetryCount(), request.paymentKey()); + } + return tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest(), + idempotencyKey); + }); + tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), + tosspaymentsConfirmResponse.orderId(), + tosspaymentsConfirmResponse.totalAmount()); + TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult( + tosspaymentsPayment); + tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, + PaymentState.COMPLETED); + return tosspaymentsConfirmResponse; + } catch (HttpClientErrorException e) { + throw handleConfirmClientError(e, tosspaymentsPayment); + } catch (HttpServerErrorException e) { + handleConfirmServerError(e, tosspaymentsPayment); + throw e; + } catch (ResourceAccessException e) { + handleConfirmNetworkError(e, tosspaymentsPayment); + throw e; + } + } + + private RuntimeException handleConfirmClientError(HttpClientErrorException e, + TosspaymentsPayment tosspaymentsPayment) { + Failure failure = e.getResponseBodyAs(Failure.class); + if (failure == null) { + log.error("토스 결제 실패(400) - 응답 파싱 실패", e); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); + } + String code = failure.code(); + + if ("IDEMPOTENT_REQUEST_PROCESSING".equals(code)) { + log.error("토스 결제 처리 중 상태 지속 (409), paymentKey = {}", tosspaymentsPayment.getPaymentKey()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); + } + + if (TosspaymentsInternalServerErrorCode.contains(code)) { + log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); + } + + log.info("토스 결제 실패(클라이언트 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CLIENT_BAD_REQUEST); + return new BadRequestException(failure.message(), e); + } + + private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPayment tosspaymentsPayment) { + try { + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs( + TosspaymentsPaymentResponse.class); + TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult( + tosspaymentsPayment); + tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, + PaymentState.NEED_CANCEL); + } catch (Exception parseException) { + log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } + } + + private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPayment tosspaymentsPayment) { + if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) { + if (socketTimeoutException.getMessage().contains("Connect timed out")) { + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT); + return; + } + if (socketTimeoutException.getMessage().contains("Read timed out")) { + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + return; + } + } + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } + + public void cancelPayment(CancelRequest request) { + TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest( + request.cancelReason()); + try { + TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), + tosspaymentsPaymentCancelRequest); + tosspaymentsTransactionService.applyCancelResult(response); + } catch (HttpClientErrorException e) { + Failure failure = e.getResponseBodyAs(Failure.class); + if (failure == null) { + log.error("결제 취소 실패(400) - 응답 파싱 실패, paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); + } + log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(), + failure.message()); + throw new BadRequestException(failure.message(), e); + } catch (HttpServerErrorException e) { + log.error("결제 취소 실패(5xx) - paymentKey: {}, status: {}", request.paymentKey(), e.getStatusCode()); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); + } catch (ResourceAccessException e) { + log.error("결제 취소 네트워크 오류 - paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage(), e); + } catch (Exception e) { + log.error("결제 취소 중 예상치 못한 오류 - paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); + } + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java new file mode 100644 index 00000000..d9ad13f3 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java @@ -0,0 +1,32 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.global.exception.NotFoundException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class TosspaymentsPaymentResultService { + + private final TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Transactional + public TosspaymentsPaymentResult save(TosspaymentsPaymentResult tosspaymentsPaymentResult) { + return tosspaymentsPaymentResultRepository.save(tosspaymentsPaymentResult); + } + + @Transactional(readOnly = true) + public TosspaymentsPaymentResult readByTosspaymentsPaymentId(Long tosspaymentsPaymentId) { + return tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(tosspaymentsPaymentId) + .orElseThrow(() -> { + log.error("결제 결과 조회 실패 - tosspaymentsPaymentId: {}", tosspaymentsPaymentId); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_RESULT_NOT_FOUND.getMessage()); + }); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java new file mode 100644 index 00000000..7304853c --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java @@ -0,0 +1,50 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.global.exception.NotFoundException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class TosspaymentsPaymentService { + + private final TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public TosspaymentsPayment saveTosspaymentsPayment(ConfirmRequest request) { + return tosspaymentsPaymentRepository.save(request.toTosspaymentsPayment()); + } + + @Transactional(readOnly = true) + public TosspaymentsPayment readById(Long id) { + return tosspaymentsPaymentRepository.findById(id) + .orElseThrow(() -> { + log.error("결제 정보 조회 실패 - id: {}", id); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_NOT_FOUND_BY_ID.getMessage()); + }); + } + + @Transactional(readOnly = true) + public TosspaymentsPayment readByPaymentKey(String paymentKey) { + return tosspaymentsPaymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> { + log.error("결제 정보 조회 실패 - paymentKey: {}", paymentKey); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_NOT_FOUND_BY_PAYMENT_KEY.getMessage()); + }); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateState(Long tosspaymentsPaymentId, PaymentState state) { + TosspaymentsPayment tosspaymentsPayment = readById(tosspaymentsPaymentId); + tosspaymentsPayment.updateState(state); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java new file mode 100644 index 00000000..7cd275c8 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java @@ -0,0 +1,46 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TosspaymentsTransactionService { + + private final TosspaymentsPaymentService tosspaymentsPaymentService; + private final TosspaymentsPaymentResultService tosspaymentsPaymentResultService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public TosspaymentsPaymentResult applyTosspaymentsPaymentResult(TosspaymentsPaymentResult tosspaymentsPaymentResult, PaymentState paymentState) { + TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.readById(tosspaymentsPaymentResult.getTosspaymentsPayment().getId()); + tosspaymentsPayment.updateState(paymentState); + return tosspaymentsPaymentResultService.save(tosspaymentsPaymentResult); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void applyCancelResult(TosspaymentsPaymentResponse response) { + TosspaymentsPayment payment = tosspaymentsPaymentService.readByPaymentKey(response.paymentKey()); + payment.updateState(PaymentState.CANCELED); + + TosspaymentsPaymentResult result = tosspaymentsPaymentResultService.readByTosspaymentsPaymentId(payment.getId()); + + if (response.cancels() != null && !response.cancels().isEmpty()) { + TosspaymentsCancel tosspaymentsCancel = response.cancels().get(0); + result.updateCancelInfo( + tosspaymentsCancel.cancelReason(), + tosspaymentsCancel.canceledAt(), + tosspaymentsCancel.easyPayDiscountAmount(), + response.lastTransactionKey(), + tosspaymentsCancel.cancelStatus(), + response.status() + ); + } + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java b/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java new file mode 100644 index 00000000..25c9ef29 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java @@ -0,0 +1,15 @@ +package com.samhap.kokomen.payment.service.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CancelRequest( + @NotBlank(message = "paymentKey는 필수값입니다.") + @Size(max = 200, message = "paymentKey는 최대 200자입니다.") + String paymentKey, + + @NotBlank(message = "cancelReason은 필수값입니다.") + @Size(max = 200, message = "cancelReason은 최대 200자입니다.") + String cancelReason +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java b/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java new file mode 100644 index 00000000..efd45b2d --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.service.dto; + +public record Checkout( + String url +) { + + public static Checkout from(com.samhap.kokomen.payment.external.dto.Checkout checkout) { + return new Checkout(checkout.url()); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java b/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java new file mode 100644 index 00000000..f0e5fe8b --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java @@ -0,0 +1,36 @@ +package com.samhap.kokomen.payment.service.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.global.infrastructure.ObjectToStringDeserializer; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.external.dto.TosspaymentsConfirmRequest; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ConfirmRequest( + @NotBlank(message = "payment_key는 비어있거나 공백일 수 없습니다.") + String paymentKey, + @NotBlank(message = "order_id는 비어있거나 공백일 수 없습니다.") + String orderId, + @NotNull(message = "total_amount는 null일 수 없습니다.") + Long totalAmount, + @NotBlank(message = "order_name은 비어있거나 공백일 수 없습니다.") + String orderName, + @NotNull(message = "member_id는 null일 수 없습니다.") + Long memberId, + @JsonDeserialize(using = ObjectToStringDeserializer.class) + @NotBlank(message = "metadata는 비어있거나 공백일 수 없습니다.") + String metadata, + @NotNull(message = "service_type은 null일 수 없습니다.") + ServiceType serviceType +) { + + public TosspaymentsConfirmRequest toTosspaymentsConfirmRequest() { + return new TosspaymentsConfirmRequest(paymentKey, orderId, totalAmount); + } + + public TosspaymentsPayment toTosspaymentsPayment() { + return new TosspaymentsPayment(paymentKey, memberId, orderId, orderName, totalAmount, metadata, serviceType); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java b/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java new file mode 100644 index 00000000..76ac5eef --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java @@ -0,0 +1,16 @@ +package com.samhap.kokomen.payment.service.dto; + +public record EasyPay( + String provider, + Long amount, + Long discountAmount +) { + + public static EasyPay from(com.samhap.kokomen.payment.external.dto.EasyPay easyPay) { + return new EasyPay( + easyPay.provider(), + easyPay.amount(), + easyPay.discountAmount() + ); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java b/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java new file mode 100644 index 00000000..6d43f105 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java @@ -0,0 +1,11 @@ +package com.samhap.kokomen.payment.service.dto; + +public record Failure( + String code, + String message +) { + + public static Failure from(com.samhap.kokomen.payment.external.dto.Failure failure) { + return new Failure(failure.code(), failure.message()); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java b/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java new file mode 100644 index 00000000..190640f6 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java @@ -0,0 +1,74 @@ +package com.samhap.kokomen.payment.service.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.global.infrastructure.ObjectToStringDeserializer; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.dto.TossDateTimeDeserializer; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import java.time.LocalDateTime; +import java.util.List; + +public record PaymentResponse( + String paymentKey, + PaymentType type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Long totalAmount, + Long balanceAmount, + TosspaymentsStatus status, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime requestedAt, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime approvedAt, + String lastTransactionKey, + Long suppliedAmount, + Long vat, + Long taxFreeAmount, + Long taxExemptionAmount, + boolean isPartialCancelable, + @JsonDeserialize(using = ObjectToStringDeserializer.class) + String metadata, + Receipt receipt, + Checkout checkout, + EasyPay easyPay, + String country, + Failure failure, + List cancels +) { + + public static PaymentResponse from(TosspaymentsPaymentResponse response) { + return new PaymentResponse( + response.paymentKey(), + response.type(), + response.orderId(), + response.orderName(), + response.mId(), + response.currency(), + response.method(), + response.totalAmount(), + response.balanceAmount(), + response.status(), + response.requestedAt(), + response.approvedAt(), + response.lastTransactionKey(), + response.suppliedAmount(), + response.vat(), + response.taxFreeAmount(), + response.taxExemptionAmount(), + response.isPartialCancelable(), + response.metadata(), + response.receipt() != null ? Receipt.from(response.receipt()) : null, + response.checkout() != null ? Checkout.from(response.checkout()) : null, + response.easyPay() != null ? EasyPay.from(response.easyPay()) : null, + response.country(), + response.failure() != null ? Failure.from(response.failure()) : null, + response.cancels() != null ? response.cancels().stream() + .map(TosspaymentsCancel::from) + .toList() : null + ); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java b/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java new file mode 100644 index 00000000..975278b1 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.service.dto; + +public record Receipt( + String url +) { + + public static Receipt from(com.samhap.kokomen.payment.external.dto.Receipt receipt) { + return new Receipt(receipt.url()); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java b/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java new file mode 100644 index 00000000..6cbe303e --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java @@ -0,0 +1,37 @@ +package com.samhap.kokomen.payment.service.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.payment.external.dto.TossDateTimeDeserializer; +import java.time.LocalDateTime; + +public record TosspaymentsCancel( + String transactionKey, + String cancelReason, + Long taxExemptionAmount, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime canceledAt, + Long easyPayDiscountAmount, + String receiptKey, + Long cancelAmount, + Long taxFreeAmount, + Long refundableAmount, + String cancelStatus, + String cancelRequestId +) { + + public static TosspaymentsCancel from(com.samhap.kokomen.payment.external.dto.TosspaymentsCancel cancel) { + return new TosspaymentsCancel( + cancel.transactionKey(), + cancel.cancelReason(), + cancel.taxExemptionAmount(), + cancel.canceledAt(), + cancel.easyPayDiscountAmount(), + cancel.receiptKey(), + cancel.cancelAmount(), + cancel.taxFreeAmount(), + cancel.refundableAmount(), + cancel.cancelStatus(), + cancel.cancelRequestId() + ); + } +} From 57148bf5721888d3cdeb369c8f432e4b3b6d360f Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 24 Feb 2026 18:12:05 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20payment=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=97=90=EC=84=9C=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=ED=98=B8=EC=B6=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/aop/PaymentLoggingAspect.java | 27 --------- .../kokomen/token/dto/ConfirmRequest.java | 12 ---- .../kokomen/token/dto/PaymentResponse.java | 11 ---- .../kokomen/token/dto/RefundRequest.java | 7 --- .../token/dto/TokenPurchaseRequest.java | 24 ++++++-- .../kokomen/token/external/PaymentClient.java | 55 ------------------- .../token/external/PaymentClientBuilder.java | 50 ----------------- .../token/service/TokenFacadeService.java | 20 ++++--- src/main/resources/application-dev.yml | 4 -- src/main/resources/application-load-test.yml | 4 -- src/main/resources/application-local.yml | 4 -- src/main/resources/application-prod.yml | 4 -- .../com/samhap/kokomen/global/BaseTest.java | 4 +- .../token/controller/TokenControllerTest.java | 12 ++-- src/test/resources/application.yml | 4 -- 15 files changed, 41 insertions(+), 201 deletions(-) delete mode 100644 src/main/java/com/samhap/kokomen/global/aop/PaymentLoggingAspect.java delete mode 100644 src/main/java/com/samhap/kokomen/token/dto/ConfirmRequest.java delete mode 100644 src/main/java/com/samhap/kokomen/token/dto/PaymentResponse.java delete mode 100644 src/main/java/com/samhap/kokomen/token/dto/RefundRequest.java delete mode 100644 src/main/java/com/samhap/kokomen/token/external/PaymentClient.java delete mode 100644 src/main/java/com/samhap/kokomen/token/external/PaymentClientBuilder.java diff --git a/src/main/java/com/samhap/kokomen/global/aop/PaymentLoggingAspect.java b/src/main/java/com/samhap/kokomen/global/aop/PaymentLoggingAspect.java deleted file mode 100644 index 44c4fe1a..00000000 --- a/src/main/java/com/samhap/kokomen/global/aop/PaymentLoggingAspect.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.samhap.kokomen.global.aop; - -import lombok.extern.slf4j.Slf4j; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; - -@Slf4j -@Order(1) -@Aspect -@Component -public class PaymentLoggingAspect { - - @Around("execution(* com.samhap.kokomen.token.external.PaymentClient.*(..))") - public Object logPaymentApiCall(ProceedingJoinPoint joinPoint) throws Throwable { - String methodName = joinPoint.getSignature().getName(); - Object[] args = joinPoint.getArgs(); - - log.info("[Payment API 요청] {} - args: {}", methodName, args); - - Object result = joinPoint.proceed(); - log.info("[Payment API 응답] {} - response: {}", methodName, result); - return result; - } -} diff --git a/src/main/java/com/samhap/kokomen/token/dto/ConfirmRequest.java b/src/main/java/com/samhap/kokomen/token/dto/ConfirmRequest.java deleted file mode 100644 index f5225e31..00000000 --- a/src/main/java/com/samhap/kokomen/token/dto/ConfirmRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.samhap.kokomen.token.dto; - -public record ConfirmRequest( - String paymentKey, - String orderId, - Long totalAmount, - String orderName, - Long memberId, - PurchaseMetadata metadata, - String serviceType -) { -} diff --git a/src/main/java/com/samhap/kokomen/token/dto/PaymentResponse.java b/src/main/java/com/samhap/kokomen/token/dto/PaymentResponse.java deleted file mode 100644 index 8eae1b5c..00000000 --- a/src/main/java/com/samhap/kokomen/token/dto/PaymentResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.samhap.kokomen.token.dto; - -public record PaymentResponse( - String method, - EasyPay easyPay -) { - public record EasyPay( - String provider - ) { - } -} \ No newline at end of file diff --git a/src/main/java/com/samhap/kokomen/token/dto/RefundRequest.java b/src/main/java/com/samhap/kokomen/token/dto/RefundRequest.java deleted file mode 100644 index b2471069..00000000 --- a/src/main/java/com/samhap/kokomen/token/dto/RefundRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.samhap.kokomen.token.dto; - -public record RefundRequest( - String paymentKey, - String cancelReason -) { -} diff --git a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java index cff738c3..977cbc53 100644 --- a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java +++ b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java @@ -1,5 +1,10 @@ package com.samhap.kokomen.token.dto; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; import com.samhap.kokomen.product.domain.TokenProduct; import com.samhap.kokomen.token.domain.TokenPurchase; import jakarta.validation.constraints.NotBlank; @@ -20,7 +25,7 @@ public record TokenPurchaseRequest( String productName ) { - public ConfirmRequest toConfirmRequest(Long memberId) { + public ConfirmRequest toPaymentConfirmRequest(Long memberId, ObjectMapper objectMapper) { TokenProduct product = TokenProduct.valueOf(productName); PurchaseMetadata metadata = new PurchaseMetadata( productName, @@ -28,18 +33,25 @@ public ConfirmRequest toConfirmRequest(Long memberId) { product.getUnitPrice() ); + String metadataJson; + try { + metadataJson = objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + throw new InternalServerErrorException("metadata 직렬화 중 오류가 발생했습니다.", e); + } + return new ConfirmRequest( paymentKey, orderId, price, orderName, memberId, - metadata, - "INTERVIEW" + metadataJson, + ServiceType.INTERVIEW ); } - public TokenPurchase toTokenPurchase(Long memberId, PaymentResponse paymentResponse) { + public TokenPurchase toTokenPurchase(Long memberId, String paymentMethod, String easyPayProvider) { TokenProduct product = TokenProduct.valueOf(productName); return TokenPurchase.builder() .memberId(memberId) @@ -50,8 +62,8 @@ public TokenPurchase toTokenPurchase(Long memberId, PaymentResponse paymentRespo .productName(productName) .purchaseCount(getTokenCountFromProduct(product)) .unitPrice(product.getUnitPrice()) - .paymentMethod(paymentResponse.method()) - .easyPayProvider(paymentResponse.easyPay() != null ? paymentResponse.easyPay().provider() : null) + .paymentMethod(paymentMethod) + .easyPayProvider(easyPayProvider) .build(); } diff --git a/src/main/java/com/samhap/kokomen/token/external/PaymentClient.java b/src/main/java/com/samhap/kokomen/token/external/PaymentClient.java deleted file mode 100644 index 4591a56b..00000000 --- a/src/main/java/com/samhap/kokomen/token/external/PaymentClient.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.samhap.kokomen.token.external; - -import com.samhap.kokomen.global.annotation.ExecutionTimer; -import com.samhap.kokomen.global.exception.InternalApiException; -import com.samhap.kokomen.token.dto.ConfirmRequest; -import com.samhap.kokomen.token.dto.PaymentResponse; -import com.samhap.kokomen.token.dto.RefundRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientResponseException; - -@Slf4j -@ExecutionTimer -@Component -public class PaymentClient { - - private final RestClient restClient; - - public PaymentClient(PaymentClientBuilder paymentClientBuilder) { - this.restClient = paymentClientBuilder.getPaymentClientBuilder().build(); - } - - public PaymentResponse confirmPayment(ConfirmRequest confirmRequest) { - try { - return restClient.post() - .uri("/internal/v1/payments/confirm") - .body(confirmRequest) - .retrieve() - .body(PaymentResponse.class); - } catch (RestClientResponseException e) { - log.error("[Payment API 오류] confirmPayment - 상태 코드: {}, 응답: {}", e.getStatusCode(), e.getResponseBodyAsString(), e); - throw new InternalApiException("Payment API 서버로부터 오류 응답을 받았습니다. 상태 코드: " + e.getStatusCode(), e); - } catch (Exception e) { - log.error("[Payment API 오류] confirmPayment - 예상치 못한 오류", e); - throw new InternalApiException("Payment API 호출 중 예상치 못한 오류가 발생했습니다.", e); - } - } - - public void refundPayment(RefundRequest refundRequest) { - try { - restClient.post() - .uri("/internal/v1/payments/cancel") - .body(refundRequest) - .retrieve() - .toBodilessEntity(); - } catch (RestClientResponseException e) { - log.error("[Payment API 오류] refundPayment - 상태 코드: {}, 응답: {}", e.getStatusCode(), e.getResponseBodyAsString(), e); - throw new InternalApiException("Payment API 서버로부터 오류 응답을 받았습니다. 상태 코드: " + e.getStatusCode(), e); - } catch (Exception e) { - log.error("[Payment API 오류] refundPayment - 예상치 못한 오류", e); - throw new InternalApiException("Payment API 호출 중 예상치 못한 오류가 발생했습니다.", e); - } - } -} diff --git a/src/main/java/com/samhap/kokomen/token/external/PaymentClientBuilder.java b/src/main/java/com/samhap/kokomen/token/external/PaymentClientBuilder.java deleted file mode 100644 index 47eea34c..00000000 --- a/src/main/java/com/samhap/kokomen/token/external/PaymentClientBuilder.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.samhap.kokomen.token.external; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Duration; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.slf4j.MDC; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; - -@Slf4j -@Getter -@Component -public class PaymentClientBuilder { - - private final RestClient.Builder paymentClientBuilder; - - public PaymentClientBuilder( - RestClient.Builder builder, - ObjectMapper objectMapper, - @Value("${payment.base-url}") String paymentBaseUrl, - @Value("${payment.connect-timeout}") Duration connectTimeout, - @Value("${payment.read-timeout}") Duration readTimeout - ) { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(connectTimeout); - requestFactory.setReadTimeout(readTimeout); - - this.paymentClientBuilder = builder - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .requestInterceptor((request, body, execution) -> { - String requestId = MDC.get("requestId"); - if (requestId != null) { - request.getHeaders().add("X-RequestID", requestId); - } - return execution.execute(request, body); - }) - .requestFactory(requestFactory) - .baseUrl(paymentBaseUrl) - .messageConverters(converters -> { - converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); - converters.add(new MappingJackson2HttpMessageConverter(objectMapper)); - }); - } -} diff --git a/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java b/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java index dd16d7a8..dc080a05 100644 --- a/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java +++ b/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java @@ -1,18 +1,20 @@ package com.samhap.kokomen.token.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.samhap.kokomen.global.annotation.DistributedLock; import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.payment.service.PaymentFacadeService; +import com.samhap.kokomen.payment.service.dto.CancelRequest; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; import com.samhap.kokomen.product.domain.TokenProduct; import com.samhap.kokomen.token.domain.RefundReasonCode; import com.samhap.kokomen.token.domain.TokenPurchase; import com.samhap.kokomen.token.domain.TokenPurchaseState; -import com.samhap.kokomen.token.dto.PaymentResponse; -import com.samhap.kokomen.token.dto.RefundRequest; import com.samhap.kokomen.token.dto.TokenPurchaseRequest; import com.samhap.kokomen.token.dto.TokenPurchaseResponse; import com.samhap.kokomen.token.dto.TokenPurchaseResponses; import com.samhap.kokomen.token.dto.TokenRefundRequest; -import com.samhap.kokomen.token.external.PaymentClient; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,7 +31,8 @@ public class TokenFacadeService { private final TokenService tokenService; private final TokenPurchaseService tokenPurchaseService; - private final PaymentClient paymentClient; + private final PaymentFacadeService paymentFacadeService; + private final ObjectMapper objectMapper; @DistributedLock(prefix = "token", key = "#memberId") @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -41,8 +44,11 @@ public void purchaseTokens(Long memberId, TokenPurchaseRequest request) { log.info("토큰 구매 요청 - memberId: {}, paymentKey: {}, tokenCount: {}, amount: {}", memberId, request.paymentKey(), tokenCount, totalAmount); - PaymentResponse paymentResponse = paymentClient.confirmPayment(request.toConfirmRequest(memberId)); - tokenPurchaseService.saveTokenPurchase(request.toTokenPurchase(memberId, paymentResponse)); + ConfirmRequest confirmRequest = request.toPaymentConfirmRequest(memberId, objectMapper); + PaymentResponse paymentResponse = paymentFacadeService.confirmPayment(confirmRequest); + tokenPurchaseService.saveTokenPurchase( + request.toTokenPurchase(memberId, paymentResponse.method(), + paymentResponse.easyPay() != null ? paymentResponse.easyPay().provider() : null)); tokenService.addPaidTokens(memberId, tokenCount); log.info("토큰 구매 완료 - memberId: {}, paymentKey: {}, 증가된 토큰: {}", memberId, request.paymentKey(), tokenCount); @@ -134,7 +140,7 @@ public void refundTokens(Long memberId, Long tokenPurchaseId, TokenRefundRequest "토큰 환불 요청 - memberId: {}, tokenPurchaseId: {}, paymentKey: {}, refundTokenCount: {}, refundReasonCode: {}, refundReasonText: {}", memberId, tokenPurchaseId, paymentKey, refundTokenCount, refundReasonCode, refundReasonText); - paymentClient.refundPayment(new RefundRequest(paymentKey, refundReasonText)); + paymentFacadeService.cancelPayment(new CancelRequest(paymentKey, refundReasonText)); tokenPurchaseService.refundTokenPurchase(tokenPurchase, refundReasonCode, request.refundReasonText()); tokenService.refundPaidTokenCount(memberId, refundTokenCount); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 16a9ec94..2a2a7f74 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -14,10 +14,6 @@ server: cookie: domain: kokomen.kr secure: false -payment: - base-url: http://kokomen-payment-dev-internal:8080 - connect-timeout: 3s - read-timeout: 10s aws: root-question-s3-path: dev/root-question-voice/ next-question-s3-path: dev/next-question-voice/ diff --git a/src/main/resources/application-load-test.yml b/src/main/resources/application-load-test.yml index 1d7af6f9..07b1e458 100644 --- a/src/main/resources/application-load-test.yml +++ b/src/main/resources/application-load-test.yml @@ -14,10 +14,6 @@ server: cookie: domain: kokomen.kr secure: true -payment: - base-url: ${PAYMENT_BASE_URL_LOAD_TEST} - connect-timeout: 3s - read-timeout: 3s aws: root-question-s3-path: dev/root-question-voice/ next-question-s3-path: dev/next-question-voice/ diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index a3519c3e..6a5bb4e8 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -13,10 +13,6 @@ server: session: cookie: secure: true -payment: - base-url: http://kokomen-payment-local-internal:8080 - connect-timeout: 3s - read-timeout: 3s aws: root-question-s3-path: local/root-question-voice/ next-question-s3-path: local/next-question-voice/ diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 3f600e86..cb1ef7fe 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -14,10 +14,6 @@ server: cookie: domain: kokomen.kr secure: true -payment: - base-url: ${PAYMENT_BASE_URL_PROD} - connect-timeout: 3s - read-timeout: 10s aws: root-question-s3-path: prod/root-question-voice/ next-question-s3-path: prod/next-question-voice/ diff --git a/src/test/java/com/samhap/kokomen/global/BaseTest.java b/src/test/java/com/samhap/kokomen/global/BaseTest.java index 8ddf3ff0..e066ef72 100644 --- a/src/test/java/com/samhap/kokomen/global/BaseTest.java +++ b/src/test/java/com/samhap/kokomen/global/BaseTest.java @@ -9,7 +9,7 @@ import com.samhap.kokomen.interview.external.SupertoneClient; import com.samhap.kokomen.interview.service.QuestionGenerationAsyncService; import com.samhap.kokomen.resume.external.ResumeEvaluationGptClient; -import com.samhap.kokomen.token.external.PaymentClient; +import com.samhap.kokomen.payment.service.PaymentFacadeService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -33,7 +33,7 @@ public abstract class BaseTest { @MockitoBean protected S3Client s3Client; @MockitoBean - protected PaymentClient paymentClient; + protected PaymentFacadeService paymentFacadeService; @MockitoBean protected InterviewProceedGptClient interviewProceedGptClient; @MockitoBean diff --git a/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java b/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java index 76405ffb..eb909ea5 100644 --- a/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java +++ b/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java @@ -29,8 +29,7 @@ import com.samhap.kokomen.token.domain.RefundReasonCode; import com.samhap.kokomen.token.domain.TokenPurchase; import com.samhap.kokomen.token.domain.TokenPurchaseState; -import com.samhap.kokomen.token.dto.PaymentResponse; -import com.samhap.kokomen.token.dto.PaymentResponse.EasyPay; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; import com.samhap.kokomen.token.dto.TokenPurchaseRequest; import com.samhap.kokomen.token.dto.TokenRefundRequest; import com.samhap.kokomen.token.repository.TokenPurchaseRepository; @@ -103,7 +102,12 @@ class TokenControllerTest extends BaseControllerTest { "토큰 10개", "TOKEN_10" ); - given(paymentClient.confirmPayment(any())).willReturn(new PaymentResponse("간편결제", new EasyPay("카카오페이"))); + given(paymentFacadeService.confirmPayment(any())).willReturn(new PaymentResponse( + "test_paymentKey", null, "test_orderId", "토큰 10개", "mId", "KRW", + "간편결제", 500L, 500L, null, null, null, null, null, null, null, null, + false, null, null, null, + new com.samhap.kokomen.payment.service.dto.EasyPay("카카오페이", null, null), + null, null, null)); long initialPaidTokens = tokenService.readPaidTokenCount(member.getId()); // when & then @@ -541,7 +545,7 @@ class TokenControllerTest extends BaseControllerTest { session.setAttribute("MEMBER_ID", member.getId()); TokenRefundRequest request = new TokenRefundRequest(RefundReasonCode.CHANGE_OF_MIND, null); - willDoNothing().given(paymentClient).refundPayment(any()); + willDoNothing().given(paymentFacadeService).cancelPayment(any()); long initialPaidTokens = tokenService.readPaidTokenCount(member.getId()); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index aeca4662..404fab48 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -22,10 +22,6 @@ oauth: client-id: test_google_client_id client-secret: test_google_client_secret -payment: - base-url: http://kokomen-payment-test - connect-timeout: 3s - read-timeout: 3s tosspayments: widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 retry: From 2dc9ea1eaa18df4569d269bf950b412e2f105ef4 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 24 Feb 2026 18:34:51 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/samhap/kokomen/global/BaseTest.java | 4 +- .../TosspaymentsPaymentFixtureBuilder.java | 66 +++ ...sspaymentsPaymentResultFixtureBuilder.java | 106 +++++ .../domain/TosspaymentsPaymentResultTest.java | 42 ++ .../domain/TosspaymentsPaymentTest.java | 67 +++ .../TosspaymentsPaymentRepositoryTest.java | 52 +++ .../service/PaymentFacadeServiceTest.java | 382 ++++++++++++++++++ .../TosspaymentsTransactionServiceTest.java | 101 +++++ .../token/controller/TokenControllerTest.java | 4 + 9 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentFixtureBuilder.java create mode 100644 src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java create mode 100644 src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java create mode 100644 src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java create mode 100644 src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java create mode 100644 src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java create mode 100644 src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java diff --git a/src/test/java/com/samhap/kokomen/global/BaseTest.java b/src/test/java/com/samhap/kokomen/global/BaseTest.java index e066ef72..147c4ea9 100644 --- a/src/test/java/com/samhap/kokomen/global/BaseTest.java +++ b/src/test/java/com/samhap/kokomen/global/BaseTest.java @@ -9,7 +9,7 @@ import com.samhap.kokomen.interview.external.SupertoneClient; import com.samhap.kokomen.interview.service.QuestionGenerationAsyncService; import com.samhap.kokomen.resume.external.ResumeEvaluationGptClient; -import com.samhap.kokomen.payment.service.PaymentFacadeService; +import com.samhap.kokomen.payment.external.TosspaymentsClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -33,7 +33,7 @@ public abstract class BaseTest { @MockitoBean protected S3Client s3Client; @MockitoBean - protected PaymentFacadeService paymentFacadeService; + protected TosspaymentsClient tosspaymentsClient; @MockitoBean protected InterviewProceedGptClient interviewProceedGptClient; @MockitoBean diff --git a/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentFixtureBuilder.java b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentFixtureBuilder.java new file mode 100644 index 00000000..56fd8249 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentFixtureBuilder.java @@ -0,0 +1,66 @@ +package com.samhap.kokomen.global.fixture.payment; + +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; + +public class TosspaymentsPaymentFixtureBuilder { + + private String paymentKey; + private Long memberId; + private String orderId; + private String orderName; + private Long totalAmount; + private String metadata; + private ServiceType serviceType; + + public static TosspaymentsPaymentFixtureBuilder builder() { + return new TosspaymentsPaymentFixtureBuilder(); + } + + public TosspaymentsPaymentFixtureBuilder paymentKey(String paymentKey) { + this.paymentKey = paymentKey; + return this; + } + + public TosspaymentsPaymentFixtureBuilder memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public TosspaymentsPaymentFixtureBuilder orderId(String orderId) { + this.orderId = orderId; + return this; + } + + public TosspaymentsPaymentFixtureBuilder orderName(String orderName) { + this.orderName = orderName; + return this; + } + + public TosspaymentsPaymentFixtureBuilder totalAmount(Long totalAmount) { + this.totalAmount = totalAmount; + return this; + } + + public TosspaymentsPaymentFixtureBuilder metadata(String metadata) { + this.metadata = metadata; + return this; + } + + public TosspaymentsPaymentFixtureBuilder serviceType(ServiceType serviceType) { + this.serviceType = serviceType; + return this; + } + + public TosspaymentsPayment build() { + return new TosspaymentsPayment( + paymentKey != null ? paymentKey : "test_payment_key_123", + memberId != null ? memberId : 1L, + orderId != null ? orderId : "order_123", + orderName != null ? orderName : "테스트 주문", + totalAmount != null ? totalAmount : 10000L, + metadata != null ? metadata : "{\"test\": \"metadata\"}", + serviceType != null ? serviceType : ServiceType.INTERVIEW + ); + } +} diff --git a/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java new file mode 100644 index 00000000..a8d221a8 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java @@ -0,0 +1,106 @@ +package com.samhap.kokomen.global.fixture.payment; + +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import java.time.LocalDateTime; + +public class TosspaymentsPaymentResultFixtureBuilder { + + private TosspaymentsPayment tosspaymentsPayment; + private PaymentType type; + private String mId; + private String currency; + private Long totalAmount; + private String method; + private Long balanceAmount; + private TosspaymentsStatus tosspaymentsStatus; + private LocalDateTime requestedAt; + private LocalDateTime approvedAt; + private String lastTransactionKey; + private Long suppliedAmount; + private Long vat; + private Long taxFreeAmount; + private Long taxExemptionAmount; + private boolean isPartialCancelable; + private String receiptUrl; + private String easyPayProvider; + private Long easyPayAmount; + private Long easyPayDiscountAmount; + private String country; + private String failureCode; + private String failureMessage; + + public static TosspaymentsPaymentResultFixtureBuilder builder() { + return new TosspaymentsPaymentResultFixtureBuilder(); + } + + public TosspaymentsPaymentResultFixtureBuilder tosspaymentsPayment(TosspaymentsPayment tosspaymentsPayment) { + this.tosspaymentsPayment = tosspaymentsPayment; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder type(PaymentType type) { + this.type = type; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder method(String method) { + this.method = method; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder tosspaymentsStatus(TosspaymentsStatus status) { + this.tosspaymentsStatus = status; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder approvedAt(LocalDateTime approvedAt) { + this.approvedAt = approvedAt; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder receiptUrl(String receiptUrl) { + this.receiptUrl = receiptUrl; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder failureCode(String failureCode) { + this.failureCode = failureCode; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder failureMessage(String failureMessage) { + this.failureMessage = failureMessage; + return this; + } + + public TosspaymentsPaymentResult build() { + return new TosspaymentsPaymentResult( + tosspaymentsPayment, + type != null ? type : PaymentType.NORMAL, + mId != null ? mId : "tvivarepublica", + currency != null ? currency : "KRW", + totalAmount != null ? totalAmount : 10000L, + method != null ? method : "카드", + balanceAmount != null ? balanceAmount : 10000L, + tosspaymentsStatus != null ? tosspaymentsStatus : TosspaymentsStatus.DONE, + requestedAt != null ? requestedAt : LocalDateTime.now().minusMinutes(5), + approvedAt, + lastTransactionKey != null ? lastTransactionKey : "test_transaction_key", + suppliedAmount != null ? suppliedAmount : 9091L, + vat != null ? vat : 909L, + taxFreeAmount != null ? taxFreeAmount : 0L, + taxExemptionAmount != null ? taxExemptionAmount : 0L, + isPartialCancelable, + receiptUrl, + easyPayProvider, + easyPayAmount, + easyPayDiscountAmount, + country != null ? country : "KR", + failureCode, + failureMessage + ); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java new file mode 100644 index 00000000..0319b7c2 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java @@ -0,0 +1,42 @@ +package com.samhap.kokomen.payment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class TosspaymentsPaymentResultTest { + + @Test + void 취소_정보를_업데이트할_수_있다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + TosspaymentsPaymentResult result = new TosspaymentsPaymentResult( + payment, PaymentType.NORMAL, "tvivarepublica", "KRW", 10000L, "카드", + 10000L, TosspaymentsStatus.DONE, LocalDateTime.now(), LocalDateTime.now(), + "transaction_key", 9091L, 909L, 0L, 0L, true, + null, null, null, null, "KR", null, null + ); + + String cancelReason = "단순 변심"; + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + Long easyPayDiscountAmount = 0L; + String lastTransactionKey = "cancel_transaction_key"; + String cancelStatus = "DONE"; + TosspaymentsStatus tosspaymentsStatus = TosspaymentsStatus.CANCELED; + + result.updateCancelInfo(cancelReason, canceledAt, easyPayDiscountAmount, + lastTransactionKey, cancelStatus, tosspaymentsStatus); + + assertAll( + () -> assertThat(result.getCancelReason()).isEqualTo(cancelReason), + () -> assertThat(result.getCanceledAt()).isEqualTo(canceledAt), + () -> assertThat(result.getEasyPayDiscountAmount()).isEqualTo(easyPayDiscountAmount), + () -> assertThat(result.getLastTransactionKey()).isEqualTo(lastTransactionKey), + () -> assertThat(result.getCancelStatus()).isEqualTo(cancelStatus), + () -> assertThat(result.getTosspaymentsStatus()).isEqualTo(tosspaymentsStatus) + ); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java new file mode 100644 index 00000000..bc13de3a --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java @@ -0,0 +1,67 @@ +package com.samhap.kokomen.payment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import org.junit.jupiter.api.Test; + +class TosspaymentsPaymentTest { + + @Test + void 토스페이먼츠_응답_검증에_성공한다() { + String paymentKey = "payment_key"; + String orderId = "order_id"; + long totalAmount = 10000L; + TosspaymentsPayment payment = new TosspaymentsPayment( + paymentKey, 1L, orderId, "주문명", totalAmount, "{}", ServiceType.INTERVIEW + ); + + assertDoesNotThrow(() -> payment.validateTosspaymentsResult(paymentKey, orderId, totalAmount)); + } + + @Test + void paymentKey가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("wrong_key", "order_id", 10000L)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); + } + + @Test + void orderId가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "wrong_order", 10000L)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.ORDER_ID_MISMATCH.getMessage()); + } + + @Test + void totalAmount가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "order_id", 99999L)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); + } + + @Test + void 결제_상태를_변경할_수_있다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + payment.updateState(PaymentState.APPROVED); + + assertThat(payment.getState()).isEqualTo(PaymentState.APPROVED); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java b/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java new file mode 100644 index 00000000..2df61b84 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java @@ -0,0 +1,52 @@ +package com.samhap.kokomen.payment.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +class TosspaymentsPaymentRepositoryTest extends BaseTest { + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Test + void 중복된_paymentKey로_저장하면_예외가_발생한다() { + String duplicateKey = "duplicate_key"; + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey(duplicateKey) + .orderId("order_1") + .build(); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPayment duplicatePayment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey(duplicateKey) + .orderId("order_2") + .build(); + + assertThatThrownBy(() -> tosspaymentsPaymentRepository.save(duplicatePayment)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 중복된_orderId로_저장하면_예외가_발생한다() { + String duplicateOrder = "duplicate_order"; + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("key_1") + .orderId(duplicateOrder) + .build(); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPayment duplicatePayment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("key_2") + .orderId(duplicateOrder) + .build(); + + assertThatThrownBy(() -> tosspaymentsPaymentRepository.save(duplicatePayment)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java new file mode 100644 index 00000000..a105ef4f --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -0,0 +1,382 @@ +package com.samhap.kokomen.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentResultFixtureBuilder; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.dto.Failure; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import com.samhap.kokomen.payment.service.dto.CancelRequest; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +class PaymentFacadeServiceTest extends BaseTest { + + @Autowired + private PaymentFacadeService paymentFacadeService; + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Autowired + private TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Test + void 결제_승인에_성공한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())).thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isPresent(); + } + + @Test + void 서버_원인_400_에러가_발생하면_SERVER_BAD_REQUEST_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_API_KEY", "잘못된 API 키입니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage()); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.SERVER_BAD_REQUEST); + } + + @Test + void 클라이언트_원인_400_에러가_발생하면_CLIENT_BAD_REQUEST_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(BadRequestException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.CLIENT_BAD_REQUEST); + } + + @Test + void 결제_승인_시_5xx_에러_응답_파싱에_성공하면_결과를_저장하고_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) + .thenReturn(createSuccessResponse()); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(serverError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(HttpServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isPresent(); + } + + @Test + void 결제_승인_시_5xx_에러_응답_파싱에_실패하면_NEED_CANCEL_상태만_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) + .thenThrow(new RuntimeException("파싱 실패")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(serverError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(HttpServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isEmpty(); + } + + @Test + void 결제_승인_시_연결_타임아웃이_발생하면_CONNECTION_TIMEOUT_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Connect timed out"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.CONNECTION_TIMEOUT); + } + + @Test + void 결제_승인_시_읽기_타임아웃이_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Read timed out"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_SocketTimeoutException_외_네트워크_오류가_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new ConnectException("Connection refused"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_400_에러_응답_파싱에_실패하면_InternalServerErrorException을_던진다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)).thenReturn(null); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage()); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.SERVER_BAD_REQUEST); + } + + @Test + void 결제_승인_시_예상치_못한_예외가_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(new RuntimeException("예상치 못한 오류")); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(RuntimeException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_5xx_에러_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(serverError) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_연결_타임아웃_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Connect timed out"))) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_409_에러_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException conflictError = mock(HttpClientErrorException.class); + when(conflictError.getStatusCode()).thenReturn(HttpStatus.CONFLICT); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(conflictError) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_클라이언트_400_에러는_재시도하지_않는다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(BadRequestException.class); + + verify(tosspaymentsClient, times(1)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_409_재시도_소진_후_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException conflictError = mock(HttpClientErrorException.class); + when(conflictError.getStatusCode()).thenReturn(HttpStatus.CONFLICT); + when(conflictError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("IDEMPOTENT_REQUEST_PROCESSING", "이전 요청이 처리 중입니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(conflictError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + verify(tosspaymentsClient, times(3)).confirmPayment(any(), any()); + } + + @Test + void 결제_취소에_성공한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult paymentResult = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(paymentResult); + + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + TosspaymentsCancel cancel = new TosspaymentsCancel( + "cancel_tx_key", "단순 변심", 0L, + canceledAt, 0L, null, 10000L, 0L, 10000L, "DONE", null + ); + TosspaymentsPaymentResponse cancelResponse = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "cancel_tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, List.of(cancel) + ); + when(tosspaymentsClient.cancelPayment(any(), any())).thenReturn(cancelResponse); + + paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심")); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isEqualTo("단순 변심"); + assertThat(updatedResult.getCanceledAt()).isEqualTo(canceledAt); + assertThat(updatedResult.getCancelStatus()).isEqualTo("DONE"); + } + + @Test + void 결제_취소_시_400_에러가_발생하면_BadRequestException을_던진다() { + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("ALREADY_CANCELED_PAYMENT", "이미 취소된 결제입니다.")); + when(tosspaymentsClient.cancelPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(BadRequestException.class); + } + + @Test + void 결제_취소_시_400_에러_응답_파싱에_실패하면_InternalServerErrorException을_던진다() { + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)).thenReturn(null); + when(tosspaymentsClient.cancelPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + + @Test + void 결제_취소_시_5xx_에러가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR)); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + + @Test + void 결제_취소_시_네트워크_에러가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new ResourceAccessException("네트워크 오류")); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage()); + } + + @Test + void 결제_취소_시_예상치_못한_예외가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new RuntimeException("예상치 못한 오류")); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + + private ConfirmRequest createConfirmRequest() { + return new ConfirmRequest("payment_key", "order_id", 10000L, "주문명", 1L, "{}", ServiceType.INTERVIEW); + } + + private TosspaymentsPaymentResponse createSuccessResponse() { + return new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.DONE, LocalDateTime.now(), LocalDateTime.now(), + "transaction_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, null + ); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java b/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java new file mode 100644 index 00000000..8a4ac224 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java @@ -0,0 +1,101 @@ +package com.samhap.kokomen.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentResultFixtureBuilder; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class TosspaymentsTransactionServiceTest extends BaseTest { + + @Autowired + private TosspaymentsTransactionService tosspaymentsTransactionService; + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Autowired + private TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Test + void 취소_결과에_취소_정보가_있으면_결제_결과에_취소_정보를_업데이트한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult result = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(result); + + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + TosspaymentsCancel cancel = new TosspaymentsCancel( + "cancel_tx_key", "단순 변심", 0L, + canceledAt, 0L, null, 10000L, 0L, 10000L, "DONE", null + ); + TosspaymentsPaymentResponse response = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "cancel_tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, List.of(cancel) + ); + + tosspaymentsTransactionService.applyCancelResult(response); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isEqualTo("단순 변심"); + assertThat(updatedResult.getCanceledAt()).isEqualTo(canceledAt); + assertThat(updatedResult.getCancelStatus()).isEqualTo("DONE"); + } + + @Test + void 취소_결과에_취소_정보가_없으면_결제_상태만_변경한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult result = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(result); + + TosspaymentsPaymentResponse response = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, null + ); + + tosspaymentsTransactionService.applyCancelResult(response); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isNull(); + } +} diff --git a/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java b/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java index eb909ea5..cf07d565 100644 --- a/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java +++ b/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java @@ -30,6 +30,7 @@ import com.samhap.kokomen.token.domain.TokenPurchase; import com.samhap.kokomen.token.domain.TokenPurchaseState; import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import com.samhap.kokomen.payment.service.PaymentFacadeService; import com.samhap.kokomen.token.dto.TokenPurchaseRequest; import com.samhap.kokomen.token.dto.TokenRefundRequest; import com.samhap.kokomen.token.repository.TokenPurchaseRepository; @@ -38,10 +39,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.restdocs.payload.JsonFieldType; class TokenControllerTest extends BaseControllerTest { + @MockitoBean + private PaymentFacadeService paymentFacadeService; @Autowired private ObjectMapper objectMapper; @Autowired From 9c9975524a451df428fa55df45ebbf615e46ea8d Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 26 Feb 2026 13:16:32 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/aop/TosspaymentsLoggingAspect.java | 17 +++++-- .../exception/GlobalExceptionHandler.java | 5 +- .../global/exception/NotFoundException.java | 4 ++ .../payment/domain/TosspaymentsPayment.java | 25 +++++++++- .../domain/TosspaymentsPaymentResult.java | 2 + .../external/TossPaymentsClientBuilder.java | 3 +- .../dto/TossDateTimeDeserializer.java | 3 +- .../dto/TosspaymentsConfirmRequest.java | 8 +++ .../dto/TosspaymentsPaymentCancelRequest.java | 11 +++- .../dto/TosspaymentsPaymentResponse.java | 50 +++++++++---------- .../TosspaymentsTransactionService.java | 2 +- .../token/dto/TokenPurchaseRequest.java | 13 ++++- .../V40__alter_canceled_at_to_datetime6.sql | 1 + ...sspaymentsPaymentResultFixtureBuilder.java | 50 +++++++++---------- 14 files changed, 129 insertions(+), 65 deletions(-) create mode 100644 src/main/resources/db/migration/V40__alter_canceled_at_to_datetime6.sql diff --git a/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java b/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java index 88b02e43..4fa6e453 100644 --- a/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java +++ b/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java @@ -23,10 +23,17 @@ public Object logTosspaymentsApiCall(ProceedingJoinPoint joinPoint) throws Throw StopWatch stopWatch = new StopWatch(); stopWatch.start(); - Object result = joinPoint.proceed(); - stopWatch.stop(); - log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}", - methodName, stopWatch.getTotalTimeMillis(), result); - return result; + try { + Object result = joinPoint.proceed(); + stopWatch.stop(); + log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}", + methodName, stopWatch.getTotalTimeMillis(), result); + return result; + } catch (Throwable e) { + stopWatch.stop(); + log.error("[토스페이먼츠 API 에러] {} - {}ms - error: {}", + methodName, stopWatch.getTotalTimeMillis(), e.getMessage()); + throw e; + } } } diff --git a/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 01509d0f..cc9057ab 100644 --- a/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -6,14 +6,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; -import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.resource.NoResourceFoundException; -// TODO: HttpMessageNotReadableException 예외 처리 추가 @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @@ -98,7 +97,7 @@ public ResponseEntity handleNoResourceFound(NoResourceFoundException e) { public ResponseEntity handleHttpMediaTypeNotSupportedException( HttpMediaTypeNotSupportedException e) { log.warn("HttpMediaTypeNotSupportedException :: message: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) .body(new ErrorResponse("지원하지 않는 Content-Type입니다.")); } diff --git a/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java b/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java index 4c3f6131..fc7bdc1b 100644 --- a/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java +++ b/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java @@ -7,4 +7,8 @@ public class NotFoundException extends KokomenException { public NotFoundException(String message) { super(message, HttpStatus.NOT_FOUND); } + + public NotFoundException(String message, Throwable cause) { + super(message, cause, HttpStatus.NOT_FOUND); + } } diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java index 6cb7ccf5..e8dd7976 100644 --- a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -63,6 +63,7 @@ public class TosspaymentsPayment extends BaseEntity { public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, String orderName, Long totalAmount, String metadata, ServiceType serviceType) { + validateConstructorParams(paymentKey, memberId, orderId, totalAmount); this.paymentKey = paymentKey; this.memberId = memberId; this.orderId = orderId; @@ -73,13 +74,28 @@ public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, Str this.state = PaymentState.NEED_APPROVE; } + private void validateConstructorParams(String paymentKey, Long memberId, String orderId, Long totalAmount) { + if (paymentKey == null || paymentKey.isBlank()) { + throw new IllegalArgumentException("paymentKey는 필수입니다."); + } + if (memberId == null) { + throw new IllegalArgumentException("memberId는 필수입니다."); + } + if (orderId == null || orderId.isBlank()) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + } + public void updateState(PaymentState state) { this.state = state; } public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount) { if (!this.paymentKey.equals(paymentKey)) { - log.error("paymentKey 불일치 - 응답: {}, DB: {}", paymentKey, this.paymentKey); + log.error("paymentKey 불일치 - 응답: {}, DB: {}", maskPaymentKey(paymentKey), maskPaymentKey(this.paymentKey)); throw new InternalServerErrorException(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); } if (!this.orderId.equals(orderId)) { @@ -91,4 +107,11 @@ public void validateTosspaymentsResult(String paymentKey, String orderId, Long t throw new InternalServerErrorException(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); } } + + private String maskPaymentKey(String key) { + if (key == null || key.length() <= 8) { + return "***"; + } + return key.substring(0, 8) + "***"; + } } diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java index 699729a7..94323406 100644 --- a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java @@ -13,6 +13,7 @@ import jakarta.persistence.OneToOne; import java.time.LocalDateTime; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.LastModifiedDate; @@ -111,6 +112,7 @@ public class TosspaymentsPaymentResult extends BaseEntity { @Column(nullable = false) private LocalDateTime updatedAt; + @Builder public TosspaymentsPaymentResult(TosspaymentsPayment tosspaymentsPayment, PaymentType type, String mId, String currency, Long totalAmount, String method, Long balanceAmount, TosspaymentsStatus tosspaymentsStatus, LocalDateTime requestedAt, LocalDateTime approvedAt, String lastTransactionKey, Long suppliedAmount, Long vat, Long taxFreeAmount, Long taxExemptionAmount, diff --git a/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java b/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java index c4b05ab5..f2084de4 100644 --- a/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java +++ b/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.nio.charset.StandardCharsets; import java.util.Base64; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; @@ -28,7 +29,7 @@ public TossPaymentsClientBuilder( requestFactory.setConnectTimeout(3000); requestFactory.setReadTimeout(15000); - String encodedSecretKey = Base64.getEncoder().encodeToString((tossPaymentsWidgetSecretKey + ":").getBytes()); + String encodedSecretKey = Base64.getEncoder().encodeToString((tossPaymentsWidgetSecretKey + ":").getBytes(StandardCharsets.UTF_8)); this.tossPaymentsClientBuilder = builder .requestFactory(requestFactory) diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java index e2f6af8f..a002dc8b 100644 --- a/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; public class TossDateTimeDeserializer extends JsonDeserializer { @@ -14,6 +15,6 @@ public class TossDateTimeDeserializer extends JsonDeserializer { public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String dateTimeStr = p.getText(); OffsetDateTime offsetDateTime = OffsetDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_OFFSET_DATE_TIME); - return offsetDateTime.toLocalDateTime(); + return offsetDateTime.atZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDateTime(); } } diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java index 5740f87d..db38d76c 100644 --- a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java @@ -1,8 +1,16 @@ package com.samhap.kokomen.payment.external.dto; +import java.util.Objects; + public record TosspaymentsConfirmRequest( String paymentKey, String orderId, Long amount ) { + + public TosspaymentsConfirmRequest { + Objects.requireNonNull(paymentKey, "paymentKey는 필수입니다."); + Objects.requireNonNull(orderId, "orderId는 필수입니다."); + Objects.requireNonNull(amount, "amount는 필수입니다."); + } } diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java index 69f3e8f8..c7554f81 100644 --- a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java @@ -1,6 +1,15 @@ package com.samhap.kokomen.payment.external.dto; +import java.util.Objects; + public record TosspaymentsPaymentCancelRequest( - String cancelReason + String cancelReason ) { + + public TosspaymentsPaymentCancelRequest { + Objects.requireNonNull(cancelReason, "cancelReason은 필수입니다."); + if (cancelReason.isBlank()) { + throw new IllegalArgumentException("cancelReason은 비어있을 수 없습니다."); + } + } } diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java index 2d0fd87e..47334fd5 100644 --- a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java @@ -40,30 +40,30 @@ public record TosspaymentsPaymentResponse( ) { public TosspaymentsPaymentResult toTosspaymentsPaymentResult(TosspaymentsPayment tosspaymentsPayment) { - return new TosspaymentsPaymentResult( - tosspaymentsPayment, - this.type, - this.mId, - this.currency, - this.totalAmount, - this.method, - this.balanceAmount, - this.status, - this.requestedAt, - this.approvedAt, - this.lastTransactionKey, - this.suppliedAmount, - this.vat, - this.taxFreeAmount, - this.taxExemptionAmount, - this.isPartialCancelable, - this.receipt() != null ? this.receipt().url() : null, - this.easyPay() != null ? this.easyPay().provider() : null, - this.easyPay() != null ? this.easyPay().amount() : null, - this.easyPay() != null ? this.easyPay().discountAmount() : null, - this.country, - this.failure() != null ? this.failure().code() : null, - this.failure() != null ? this.failure().message() : null - ); + return TosspaymentsPaymentResult.builder() + .tosspaymentsPayment(tosspaymentsPayment) + .type(this.type) + .mId(this.mId) + .currency(this.currency) + .totalAmount(this.totalAmount) + .method(this.method) + .balanceAmount(this.balanceAmount) + .tosspaymentsStatus(this.status) + .requestedAt(this.requestedAt) + .approvedAt(this.approvedAt) + .lastTransactionKey(this.lastTransactionKey) + .suppliedAmount(this.suppliedAmount) + .vat(this.vat) + .taxFreeAmount(this.taxFreeAmount) + .taxExemptionAmount(this.taxExemptionAmount) + .isPartialCancelable(this.isPartialCancelable) + .receiptUrl(this.receipt() != null ? this.receipt().url() : null) + .easyPayProvider(this.easyPay() != null ? this.easyPay().provider() : null) + .easyPayAmount(this.easyPay() != null ? this.easyPay().amount() : null) + .easyPayDiscountAmount(this.easyPay() != null ? this.easyPay().discountAmount() : null) + .country(this.country) + .failureCode(this.failure() != null ? this.failure().code() : null) + .failureMessage(this.failure() != null ? this.failure().message() : null) + .build(); } } diff --git a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java index 7cd275c8..9a35bbcf 100644 --- a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java +++ b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java @@ -32,7 +32,7 @@ public void applyCancelResult(TosspaymentsPaymentResponse response) { TosspaymentsPaymentResult result = tosspaymentsPaymentResultService.readByTosspaymentsPaymentId(payment.getId()); if (response.cancels() != null && !response.cancels().isEmpty()) { - TosspaymentsCancel tosspaymentsCancel = response.cancels().get(0); + TosspaymentsCancel tosspaymentsCancel = response.cancels().get(response.cancels().size() - 1); result.updateCancelInfo( tosspaymentsCancel.cancelReason(), tosspaymentsCancel.canceledAt(), diff --git a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java index 977cbc53..7d381ca9 100644 --- a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java +++ b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.payment.domain.ServiceType; import com.samhap.kokomen.payment.service.dto.ConfirmRequest; @@ -26,7 +27,7 @@ public record TokenPurchaseRequest( ) { public ConfirmRequest toPaymentConfirmRequest(Long memberId, ObjectMapper objectMapper) { - TokenProduct product = TokenProduct.valueOf(productName); + TokenProduct product = readTokenProduct(productName); PurchaseMetadata metadata = new PurchaseMetadata( productName, getTokenCountFromProduct(product), @@ -52,7 +53,7 @@ public ConfirmRequest toPaymentConfirmRequest(Long memberId, ObjectMapper object } public TokenPurchase toTokenPurchase(Long memberId, String paymentMethod, String easyPayProvider) { - TokenProduct product = TokenProduct.valueOf(productName); + TokenProduct product = readTokenProduct(productName); return TokenPurchase.builder() .memberId(memberId) .paymentKey(paymentKey) @@ -70,4 +71,12 @@ public TokenPurchase toTokenPurchase(Long memberId, String paymentMethod, String private int getTokenCountFromProduct(TokenProduct product) { return product.getTokenCount(); } + + private static TokenProduct readTokenProduct(String productName) { + try { + return TokenProduct.valueOf(productName); + } catch (IllegalArgumentException e) { + throw new BadRequestException("유효하지 않은 product_name 입니다."); + } + } } diff --git a/src/main/resources/db/migration/V40__alter_canceled_at_to_datetime6.sql b/src/main/resources/db/migration/V40__alter_canceled_at_to_datetime6.sql new file mode 100644 index 00000000..d9b04b46 --- /dev/null +++ b/src/main/resources/db/migration/V40__alter_canceled_at_to_datetime6.sql @@ -0,0 +1 @@ +ALTER TABLE tosspayments_payment_result MODIFY COLUMN canceled_at DATETIME(6); diff --git a/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java index a8d221a8..9dbaf155 100644 --- a/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java +++ b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java @@ -77,30 +77,30 @@ public TosspaymentsPaymentResultFixtureBuilder failureMessage(String failureMess } public TosspaymentsPaymentResult build() { - return new TosspaymentsPaymentResult( - tosspaymentsPayment, - type != null ? type : PaymentType.NORMAL, - mId != null ? mId : "tvivarepublica", - currency != null ? currency : "KRW", - totalAmount != null ? totalAmount : 10000L, - method != null ? method : "카드", - balanceAmount != null ? balanceAmount : 10000L, - tosspaymentsStatus != null ? tosspaymentsStatus : TosspaymentsStatus.DONE, - requestedAt != null ? requestedAt : LocalDateTime.now().minusMinutes(5), - approvedAt, - lastTransactionKey != null ? lastTransactionKey : "test_transaction_key", - suppliedAmount != null ? suppliedAmount : 9091L, - vat != null ? vat : 909L, - taxFreeAmount != null ? taxFreeAmount : 0L, - taxExemptionAmount != null ? taxExemptionAmount : 0L, - isPartialCancelable, - receiptUrl, - easyPayProvider, - easyPayAmount, - easyPayDiscountAmount, - country != null ? country : "KR", - failureCode, - failureMessage - ); + return TosspaymentsPaymentResult.builder() + .tosspaymentsPayment(tosspaymentsPayment) + .type(type != null ? type : PaymentType.NORMAL) + .mId(mId != null ? mId : "tvivarepublica") + .currency(currency != null ? currency : "KRW") + .totalAmount(totalAmount != null ? totalAmount : 10000L) + .method(method != null ? method : "카드") + .balanceAmount(balanceAmount != null ? balanceAmount : 10000L) + .tosspaymentsStatus(tosspaymentsStatus != null ? tosspaymentsStatus : TosspaymentsStatus.DONE) + .requestedAt(requestedAt != null ? requestedAt : LocalDateTime.of(2024, 1, 1, 12, 0)) + .approvedAt(approvedAt) + .lastTransactionKey(lastTransactionKey != null ? lastTransactionKey : "test_transaction_key") + .suppliedAmount(suppliedAmount != null ? suppliedAmount : 9091L) + .vat(vat != null ? vat : 909L) + .taxFreeAmount(taxFreeAmount != null ? taxFreeAmount : 0L) + .taxExemptionAmount(taxExemptionAmount != null ? taxExemptionAmount : 0L) + .isPartialCancelable(isPartialCancelable) + .receiptUrl(receiptUrl) + .easyPayProvider(easyPayProvider) + .easyPayAmount(easyPayAmount) + .easyPayDiscountAmount(easyPayDiscountAmount) + .country(country != null ? country : "KR") + .failureCode(failureCode) + .failureMessage(failureMessage) + .build(); } } From d08c53935d8dbb63e07910f4b659f9c6583cd975 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 26 Feb 2026 13:31:15 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokomen/answer/service/AnswerService.java | 5 +++-- .../auth/infrastructure/SessionInvalidator.java | 3 ++- .../MemberAuthArgumentResolver.java | 5 +++-- .../samhap/kokomen/global/service/S3Service.java | 3 ++- .../interview/domain/QuestionAndAnswers.java | 5 +++-- .../interview/service/RootQuestionService.java | 7 ++++--- .../payment/domain/TosspaymentsPayment.java | 9 +++++---- .../dto/TosspaymentsPaymentCancelRequest.java | 3 ++- .../schedular/service/RecruitmentApiClient.java | 7 ++++--- .../com/samhap/kokomen/token/domain/Token.java | 6 +++--- .../kokomen/token/domain/TokenPurchase.java | 16 +++++++++------- .../kokomen/token/service/TokenService.java | 7 ++++--- .../interview/domain/QuestionAndAnswersTest.java | 2 +- 13 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java b/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java index 2c21d01a..cf2026f5 100644 --- a/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java +++ b/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java @@ -3,6 +3,7 @@ import com.samhap.kokomen.answer.domain.Answer; import com.samhap.kokomen.answer.repository.AnswerRepository; import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.NotFoundException; import com.samhap.kokomen.interview.entity.Question; import java.util.List; import java.util.Optional; @@ -23,12 +24,12 @@ public Answer saveAnswer(Answer answer) { public Answer readById(Long answerId) { return answerRepository.findById(answerId) - .orElseThrow(() -> new BadRequestException("존재하지 않는 답변입니다.")); + .orElseThrow(() -> new NotFoundException("존재하지 않는 답변입니다.")); } public Answer readByQuestionId(Long questionId) { return answerRepository.findByQuestionId(questionId) - .orElseThrow(() -> new IllegalStateException("다음 질문에 대한 답변이 존재하지 않습니다. question_id = " + questionId)); + .orElseThrow(() -> new NotFoundException("다음 질문에 대한 답변이 존재하지 않습니다. question_id = " + questionId)); } public Optional findByQuestionId(Long questionId) { diff --git a/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java b/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java index d8efebf5..d9e240ca 100644 --- a/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java +++ b/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.auth.infrastructure; +import com.samhap.kokomen.global.exception.BadRequestException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -15,7 +16,7 @@ public static void logout(HttpServletRequest request, HttpServletResponse respon Cookie jSessionIdCookie = Arrays.stream(request.getCookies()) .filter(cookie -> "JSESSIONID".equals(cookie.getName())) .findFirst() - .orElseThrow(() -> new IllegalStateException("JSESSIONID 쿠키가 요청에 존재하지 않습니다.")); + .orElseThrow(() -> new BadRequestException("JSESSIONID 쿠키가 요청에 존재하지 않습니다.")); jSessionIdCookie.setValue(""); jSessionIdCookie.setMaxAge(0); response.addCookie(jSessionIdCookie); diff --git a/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java b/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java index 10825ed4..dfda5781 100644 --- a/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java +++ b/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.annotation.Authentication; import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.global.exception.UnauthorizedException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -29,7 +30,7 @@ public Object resolveArgument(MethodParameter parameter, WebDataBinderFactory binderFactory) throws Exception { Authentication authentication = parameter.getParameterAnnotation(Authentication.class); if (authentication == null) { - throw new IllegalStateException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); + throw new InternalServerErrorException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); } boolean authenticationRequired = authentication.required(); HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); @@ -56,7 +57,7 @@ private void validateAuthentication(Long memberId, boolean authenticationRequire log.error("세션에 MEMBER_ID가 없습니다."); } if (memberId == null && authenticationRequired) { - throw new IllegalStateException("세션에 MEMBER_ID가 없습니다."); + throw new UnauthorizedException("세션에 MEMBER_ID가 없습니다."); } } } diff --git a/src/main/java/com/samhap/kokomen/global/service/S3Service.java b/src/main/java/com/samhap/kokomen/global/service/S3Service.java index bb830cdc..01135dce 100644 --- a/src/main/java/com/samhap/kokomen/global/service/S3Service.java +++ b/src/main/java/com/samhap/kokomen/global/service/S3Service.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.annotation.ExecutionTimer; import com.samhap.kokomen.global.constant.AwsConstant; +import com.samhap.kokomen.global.exception.BadRequestException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import software.amazon.awssdk.core.ResponseBytes; @@ -69,6 +70,6 @@ private String extractKeyFromCdnUrl(String cdnUrl) { if (cdnUrl.startsWith(AwsConstant.CLOUD_FRONT_DOMAIN_URL)) { return cdnUrl.substring(AwsConstant.CLOUD_FRONT_DOMAIN_URL.length()); } - throw new IllegalArgumentException("Invalid CDN URL: " + cdnUrl); + throw new BadRequestException("Invalid CDN URL: " + cdnUrl); } } diff --git a/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java b/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java index 3eafbc6c..e7f26809 100644 --- a/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java +++ b/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java @@ -20,7 +20,8 @@ public class QuestionAndAnswers { private final Interview interview; // TODO: 생성자에서 예외가 발생하더라도 객체는 힙에 남아있는지 실험해보기 - public QuestionAndAnswers(List questions, List prevAnswers, String curAnswerContent, Long curQuestionId, Interview interview) { + public QuestionAndAnswers(List questions, List prevAnswers, String curAnswerContent, + Long curQuestionId, Interview interview) { this.questions = questions.stream() .sorted(Comparator.comparing(Question::getId)) .toList(); @@ -43,7 +44,7 @@ private void validateInterviewProceed() { private void validateQuestionsAndAnswersSize() { if (questions.size() != prevAnswers.size() + 1) { - throw new IllegalArgumentException("질문과 답변의 개수가 일치하지 않습니다."); + throw new BadRequestException("질문과 답변의 개수가 일치하지 않습니다."); } } diff --git a/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java b/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java index 68503444..24378388 100644 --- a/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java +++ b/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.interview.service; import com.samhap.kokomen.category.domain.Category; +import com.samhap.kokomen.global.exception.NotFoundException; import com.samhap.kokomen.global.service.S3Service; import com.samhap.kokomen.interview.domain.QuestionVoicePathResolver; import com.samhap.kokomen.interview.entity.RootQuestion; @@ -37,7 +38,7 @@ public RootQuestion findNextRootQuestionForMember(Member member, InterviewReques RootQuestion lastRootQuestionReceived = rootQuestionRepository.findLastRootQuestionMemberReceivedByCategory(category, member.getId(), RootQuestionState.ACTIVE) - .orElseThrow(() -> new IllegalStateException("해당 카테고리의 질문을 찾을 수 없습니다.")); + .orElseThrow(() -> new NotFoundException("해당 카테고리의 질문을 찾을 수 없습니다.")); int nextOrder = lastRootQuestionReceived.getQuestionOrder() + 1; return rootQuestionRepository.findRootQuestionByCategoryAndStateAndQuestionOrder(category, @@ -48,7 +49,7 @@ public RootQuestion findNextRootQuestionForMember(Member member, InterviewReques private RootQuestion findFirstRootQuestion(Category category) { return rootQuestionRepository.findRootQuestionByCategoryAndStateAndQuestionOrder(category, RootQuestionState.ACTIVE, 1) - .orElseThrow(() -> new IllegalStateException("해당 카테고리의 질문을 찾을 수 없습니다.")); + .orElseThrow(() -> new NotFoundException("해당 카테고리의 질문을 찾을 수 없습니다.")); } public boolean isRootQuestionVoiceExists(Long rootQuestionId) { @@ -78,7 +79,7 @@ public String createAndUploadRootQuestionVoiceWithApiKey(Long rootQuestionId, St public RootQuestion readRootQuestion(Long rootQuestionId) { return rootQuestionRepository.findById(rootQuestionId) .orElseThrow( - () -> new IllegalArgumentException("루트 질문이 존재하지 않습니다. rootQuestionId = " + rootQuestionId)); + () -> new NotFoundException("루트 질문이 존재하지 않습니다. rootQuestionId = " + rootQuestionId)); } public List findAllRootQuestionByCategory(Category category) { diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java index e8dd7976..3eddbe27 100644 --- a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.payment.domain; import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.global.exception.InternalServerErrorException; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -76,16 +77,16 @@ public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, Str private void validateConstructorParams(String paymentKey, Long memberId, String orderId, Long totalAmount) { if (paymentKey == null || paymentKey.isBlank()) { - throw new IllegalArgumentException("paymentKey는 필수입니다."); + throw new BadRequestException("paymentKey는 필수입니다."); } if (memberId == null) { - throw new IllegalArgumentException("memberId는 필수입니다."); + throw new BadRequestException("memberId는 필수입니다."); } if (orderId == null || orderId.isBlank()) { - throw new IllegalArgumentException("orderId는 필수입니다."); + throw new BadRequestException("orderId는 필수입니다."); } if (totalAmount == null || totalAmount < 0) { - throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + throw new BadRequestException("totalAmount는 0 이상이어야 합니다."); } } diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java index c7554f81..9812da9a 100644 --- a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.payment.external.dto; +import com.samhap.kokomen.global.exception.BadRequestException; import java.util.Objects; public record TosspaymentsPaymentCancelRequest( @@ -9,7 +10,7 @@ public record TosspaymentsPaymentCancelRequest( public TosspaymentsPaymentCancelRequest { Objects.requireNonNull(cancelReason, "cancelReason은 필수입니다."); if (cancelReason.isBlank()) { - throw new IllegalArgumentException("cancelReason은 비어있을 수 없습니다."); + throw new BadRequestException("cancelReason은 비어있을 수 없습니다."); } } } diff --git a/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java b/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java index 573a01b2..a4adaf7b 100644 --- a/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java +++ b/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.recruit.schedular.service; +import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.recruit.schedular.dto.ApiResponse; import com.samhap.kokomen.recruit.schedular.dto.PagedData; import com.samhap.kokomen.recruit.schedular.dto.RecruitmentDto; @@ -108,13 +109,13 @@ private ApiResponse> executeApiRequest(URI uri) { private void validateResponse(ApiResponse> response, int page) { if (response == null) { - throw new IllegalStateException("API 응답이 null입니다 (페이지: " + page + ")"); + throw new BadRequestException("API 응답이 null입니다 (페이지: " + page + ")"); } if (response.getData() == null) { - throw new IllegalStateException("API 응답 데이터가 null입니다 (페이지: " + page + ")"); + throw new BadRequestException("API 응답 데이터가 null입니다 (페이지: " + page + ")"); } if (response.getData().getContent() == null) { - throw new IllegalStateException("API 응답 컨텐츠가 null입니다 (페이지: " + page + ")"); + throw new BadRequestException("API 응답 컨텐츠가 null입니다 (페이지: " + page + ")"); } if (!Boolean.TRUE.equals(response.getSuccess())) { log.error("API 응답 실패 (페이지: {}): {}", page, response); diff --git a/src/main/java/com/samhap/kokomen/token/domain/Token.java b/src/main/java/com/samhap/kokomen/token/domain/Token.java index db858020..50463809 100644 --- a/src/main/java/com/samhap/kokomen/token/domain/Token.java +++ b/src/main/java/com/samhap/kokomen/token/domain/Token.java @@ -48,7 +48,7 @@ public Token(Long memberId, TokenType type, Integer tokenCount) { public void addTokens(int count) { if (count < 0) { - throw new IllegalStateException("추가할 토큰 수는 0보다 커야 합니다."); + throw new BadRequestException("추가할 토큰 수는 0보다 커야 합니다."); } this.tokenCount += count; } @@ -62,7 +62,7 @@ public void useToken() { public void useTokens(int count) { if (count <= 0) { - throw new IllegalArgumentException("사용할 토큰 수는 0보다 커야 합니다."); + throw new BadRequestException("사용할 토큰 수는 0보다 커야 합니다."); } if (this.tokenCount < count) { throw new BadRequestException("사용할 수 있는 토큰이 부족합니다."); @@ -80,7 +80,7 @@ public boolean hasEnoughTokens(int requiredCount) { public void setTokenCount(int count) { if (count < 0) { - throw new IllegalStateException("토큰 수는 0보다 작을 수 없습니다."); + throw new BadRequestException("토큰 수는 0보다 작을 수 없습니다."); } this.tokenCount = count; } diff --git a/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java b/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java index 49eb955d..f84d34f8 100644 --- a/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java +++ b/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.token.domain; import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.global.exception.BadRequestException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -97,7 +98,8 @@ public TokenPurchase(Long memberId, String paymentKey, String orderId, Long tota public TokenPurchase(Long memberId, String paymentKey, String orderId, Long totalAmount, String orderName, String productName, Integer purchaseCount, Long unitPrice, - Integer remainingCount, TokenPurchaseState state, String paymentMethod, String easyPayProvider) { + Integer remainingCount, TokenPurchaseState state, String paymentMethod, + String easyPayProvider) { this.memberId = memberId; this.paymentKey = paymentKey; this.orderId = orderId; @@ -114,7 +116,7 @@ public TokenPurchase(Long memberId, String paymentKey, String orderId, Long tota public void useToken() { if (!hasRemainingTokens()) { - throw new IllegalStateException("사용할 수 있는 토큰이 없습니다."); + throw new BadRequestException("사용할 수 있는 토큰이 없습니다."); } this.remainingCount--; @@ -128,10 +130,10 @@ public void useToken() { public int useTokens(int count) { if (count <= 0) { - throw new IllegalArgumentException("사용할 토큰 수는 0보다 커야 합니다."); + throw new BadRequestException("사용할 토큰 수는 0보다 커야 합니다."); } if (!hasRemainingTokens()) { - throw new IllegalStateException("사용할 수 있는 토큰이 없습니다."); + throw new BadRequestException("사용할 수 있는 토큰이 없습니다."); } int tokensToUse = Math.min(count, this.remainingCount); @@ -152,15 +154,15 @@ public boolean hasRemainingTokens() { public void refund(RefundReasonCode refundReasonCode, String refundReasonText) { if (!isRefundable()) { - throw new IllegalStateException("환불 불가능한 상태입니다."); + throw new BadRequestException("환불 불가능한 상태입니다."); } if (refundReasonCode == null) { - throw new IllegalArgumentException("환불 사유 코드는 필수입니다."); + throw new BadRequestException("환불 사유 코드는 필수입니다."); } if (refundReasonCode.requiresReasonText() && (refundReasonText == null || refundReasonText.trim().isEmpty())) { - throw new IllegalArgumentException("기타 환불 사유일 때는 상세한 사유를 입력해야 합니다."); + throw new BadRequestException("기타 환불 사유일 때는 상세한 사유를 입력해야 합니다."); } this.state = TokenPurchaseState.REFUNDED; diff --git a/src/main/java/com/samhap/kokomen/token/service/TokenService.java b/src/main/java/com/samhap/kokomen/token/service/TokenService.java index 266e533e..42f2b367 100644 --- a/src/main/java/com/samhap/kokomen/token/service/TokenService.java +++ b/src/main/java/com/samhap/kokomen/token/service/TokenService.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.token.service; import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.token.domain.Token; import com.samhap.kokomen.token.domain.TokenType; import com.samhap.kokomen.token.repository.TokenRepository; @@ -30,7 +31,7 @@ public void createTokensForNewMember(Long memberId) { public void addPaidTokens(Long memberId, int count) { int updatedRows = tokenRepository.incrementTokenCountModifying(memberId, TokenType.PAID, count); if (updatedRows == 0) { - throw new IllegalStateException("유료 토큰 구매에 실패했습니다. memberId: " + memberId); + throw new InternalServerErrorException("유료 토큰 구매에 실패했습니다. memberId: " + memberId); } } @@ -68,7 +69,7 @@ public void usePaidTokens(Long memberId, int count) { public void refundPaidTokenCount(Long memberId, int count) { int updatedRows = tokenRepository.decrementTokenCountModifying(memberId, TokenType.PAID, count); if (updatedRows == 0) { - throw new IllegalStateException("유료 토큰 환불에 실패했습니다. memberId: " + memberId); + throw new InternalServerErrorException("유료 토큰 환불에 실패했습니다. memberId: " + memberId); } } @@ -101,6 +102,6 @@ public int readPaidTokenCount(Long memberId) { @Transactional(readOnly = true) public Token readTokenByMemberIdAndType(Long memberId, TokenType type) { return tokenRepository.findByMemberIdAndType(memberId, type) - .orElseThrow(() -> new IllegalStateException("해당 유형의 토큰이 존재하지 않습니다. type: " + type)); + .orElseThrow(() -> new BadRequestException("해당 유형의 토큰이 존재하지 않습니다. type: " + type)); } } diff --git a/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java b/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java index ef6fb86b..28afdea0 100644 --- a/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java +++ b/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java @@ -112,7 +112,7 @@ class QuestionAndAnswersTest { curAnswerContent, questions.get(questions.size() - 1).getId(), interview - )).isInstanceOf(IllegalArgumentException.class) + )).isInstanceOf(BadRequestException.class) .hasMessageContaining("질문과 답변의 개수가 일치하지 않습니다."); } From c3468e6e54c1d464e7bb5f65326d1c60e4ba6cbf Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 26 Feb 2026 13:45:16 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-api-dev.yml | 1 + .github/workflows/cd-api-load-test.yml | 1 + .github/workflows/cd-api-prod.yml | 2 +- docker/dev/docker-compose-dev.yml | 1 + docker/load-test/api/docker-compose-prod.yml | 1 + docker/prod/api/docker-compose-prod.yml | 2 +- 6 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd-api-dev.yml b/.github/workflows/cd-api-dev.yml index f3be1527..859fc65e 100644 --- a/.github/workflows/cd-api-dev.yml +++ b/.github/workflows/cd-api-dev.yml @@ -97,6 +97,7 @@ jobs: SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }} GOOGLE_CLIENT_ID_DEV: ${{ secrets.GOOGLE_CLIENT_ID_DEV }} GOOGLE_CLIENT_SECRET_DEV: ${{ secrets.GOOGLE_CLIENT_SECRET_DEV }} + WIDGET_SECRET_KEY_DEV: ${{ secrets.WIDGET_SECRET_KEY_DEV }} run: | export HOSTNAME=$(hostname) cd kokomen-backend/docker/dev diff --git a/.github/workflows/cd-api-load-test.yml b/.github/workflows/cd-api-load-test.yml index 82303f31..2bebf716 100644 --- a/.github/workflows/cd-api-load-test.yml +++ b/.github/workflows/cd-api-load-test.yml @@ -84,6 +84,7 @@ jobs: SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }} GOOGLE_CLIENT_ID_PROD: ${{ secrets.GOOGLE_CLIENT_ID_PROD }} GOOGLE_CLIENT_SECRET_PROD: ${{ secrets.GOOGLE_CLIENT_SECRET_PROD }} + WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }} MYSQL_HOST: ${{ secrets.MYSQL_HOST }} run: | export HOSTNAME=$(hostname) diff --git a/.github/workflows/cd-api-prod.yml b/.github/workflows/cd-api-prod.yml index fc7ef076..2c2b6b24 100644 --- a/.github/workflows/cd-api-prod.yml +++ b/.github/workflows/cd-api-prod.yml @@ -84,7 +84,7 @@ jobs: SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }} GOOGLE_CLIENT_ID_PROD: ${{ secrets.GOOGLE_CLIENT_ID_PROD }} GOOGLE_CLIENT_SECRET_PROD: ${{ secrets.GOOGLE_CLIENT_SECRET_PROD }} - PAYMENT_BASE_URL_PROD: ${{ secrets.PAYMENT_BASE_URL_PROD }} + WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }} run: | export HOSTNAME=$(hostname) cd kokomen-backend/docker/prod/api diff --git a/docker/dev/docker-compose-dev.yml b/docker/dev/docker-compose-dev.yml index 6d7afbc6..f3626479 100644 --- a/docker/dev/docker-compose-dev.yml +++ b/docker/dev/docker-compose-dev.yml @@ -23,6 +23,7 @@ services: SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN} GOOGLE_CLIENT_ID_DEV: ${GOOGLE_CLIENT_ID_DEV} GOOGLE_CLIENT_SECRET_DEV: ${GOOGLE_CLIENT_SECRET_DEV} + WIDGET_SECRET_KEY_DEV: ${WIDGET_SECRET_KEY_DEV} networks: - dev-kokomen-net diff --git a/docker/load-test/api/docker-compose-prod.yml b/docker/load-test/api/docker-compose-prod.yml index b7781ce2..482d1ffc 100644 --- a/docker/load-test/api/docker-compose-prod.yml +++ b/docker/load-test/api/docker-compose-prod.yml @@ -24,6 +24,7 @@ services: SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN} GOOGLE_CLIENT_ID_PROD: ${GOOGLE_CLIENT_ID_PROD} GOOGLE_CLIENT_SECRET_PROD: ${GOOGLE_CLIENT_SECRET_PROD} + WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD} node: image: prom/node-exporter diff --git a/docker/prod/api/docker-compose-prod.yml b/docker/prod/api/docker-compose-prod.yml index 0ce35188..cc8db796 100644 --- a/docker/prod/api/docker-compose-prod.yml +++ b/docker/prod/api/docker-compose-prod.yml @@ -24,7 +24,7 @@ services: SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN} GOOGLE_CLIENT_ID_PROD: ${GOOGLE_CLIENT_ID_PROD} GOOGLE_CLIENT_SECRET_PROD: ${GOOGLE_CLIENT_SECRET_PROD} - PAYMENT_BASE_URL_PROD: ${PAYMENT_BASE_URL_PROD} + WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD} nginx: image: nginx:1.28.0