diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 495687b..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(./gradlew:*)" - ], - "deny": [], - "ask": [] - } -} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7a50eb5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,14 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[FIX] ' +labels: '' +assignees: '' + +--- + +# 버그 내용 + +# 스크린샷 + +# 참고 사항 diff --git a/.github/ISSUE_TEMPLATE/feat-request.md b/.github/ISSUE_TEMPLATE/feat-request.md new file mode 100644 index 0000000..256b6ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feat-request.md @@ -0,0 +1,12 @@ +--- +name: Feat request +about: Suggest an idea for this project +title: '[FEAT] ' +labels: '' +assignees: '' + +--- + +# 투두 리스트 + +# 참고 사항 diff --git a/.github/ISSUE_TEMPLATE/refactor-report.md b/.github/ISSUE_TEMPLATE/refactor-report.md new file mode 100644 index 0000000..0a65dc9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-report.md @@ -0,0 +1,12 @@ +--- +name: Refactor request +about: Suggest an idea for this project +title: '[REFACTOR] ' +labels: '' +assignees: '' + +--- + +# 투두 리스트 + +# 참고 사항 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ecc9e7c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +closed # + +# 작업 내용 + +# 참고 사항 diff --git a/.github/workflows/cd-api-dev.yml b/.github/workflows/cd-api-dev.yml index 2829049..3a45e37 100644 --- a/.github/workflows/cd-api-dev.yml +++ b/.github/workflows/cd-api-dev.yml @@ -5,22 +5,7 @@ on: branches: [ develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - api_changed: ${{ steps.filter.outputs.api }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: develop - filters: | - api: - - 'api/**' - build-api: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cd-api-prod.yml b/.github/workflows/cd-api-prod.yml index 41169bd..f14798b 100644 --- a/.github/workflows/cd-api-prod.yml +++ b/.github/workflows/cd-api-prod.yml @@ -5,22 +5,7 @@ on: branches: [ main ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - api_changed: ${{ steps.filter.outputs.api }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: main - filters: | - api: - - 'api/**' - build-api: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cd-internal-dev.yml b/.github/workflows/cd-internal-dev.yml index 9cb976d..82da3ea 100644 --- a/.github/workflows/cd-internal-dev.yml +++ b/.github/workflows/cd-internal-dev.yml @@ -5,22 +5,7 @@ on: branches: [ develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - internal_changed: ${{ steps.filter.outputs.internal }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: develop - filters: | - internal: - - 'internal/**' - build-internal: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cd-internal-prod.yml b/.github/workflows/cd-internal-prod.yml index 4d0db7a..12a6f89 100644 --- a/.github/workflows/cd-internal-prod.yml +++ b/.github/workflows/cd-internal-prod.yml @@ -5,22 +5,7 @@ on: branches: [ main ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - internal_changed: ${{ steps.filter.outputs.internal }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: main - filters: | - internal: - - 'internal/**' - build-internal: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -101,6 +86,7 @@ jobs: SPRING_DATASOURCE_USERNAME_PROD: ${{ secrets.SPRING_DATASOURCE_USERNAME_PROD }} SPRING_DATASOURCE_PASSWORD_PROD: ${{ secrets.SPRING_DATASOURCE_PASSWORD_PROD }} SPRING_DATASOURCE_URL_PROD: ${{ secrets.SPRING_DATASOURCE_URL_PROD }} + WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }} run: | export HOSTNAME=$(hostname) cd kokomen-payment/docker/prod diff --git a/.github/workflows/ci-api-test.yml b/.github/workflows/ci-api-test.yml index c09101b..3bb6876 100644 --- a/.github/workflows/ci-api-test.yml +++ b/.github/workflows/ci-api-test.yml @@ -5,21 +5,7 @@ on: branches: [ main, develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - api_changed: ${{ steps.filter.outputs.api }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - api: - - 'api/**' - build: - needs: detect-changes runs-on: ubuntu-latest permissions: checks: write diff --git a/.github/workflows/ci-internal-test.yml b/.github/workflows/ci-internal-test.yml index b29029b..5ab5ca8 100644 --- a/.github/workflows/ci-internal-test.yml +++ b/.github/workflows/ci-internal-test.yml @@ -5,21 +5,7 @@ on: branches: [ main, develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - internal_changed: ${{ steps.filter.outputs.internal }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - internal: - - 'internal/**' - build: - needs: detect-changes runs-on: ubuntu-latest permissions: checks: write diff --git a/.gitignore b/.gitignore index c2065bc..a81e60d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out/ ### VS Code ### .vscode/ +.claude diff --git a/api/src/main/java/com/samhap/kokomen/global/exception/ApiErrorMessage.java b/api/src/main/java/com/samhap/kokomen/global/exception/ApiErrorMessage.java new file mode 100644 index 0000000..09e603c --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/exception/ApiErrorMessage.java @@ -0,0 +1,22 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum ApiErrorMessage { + + AUTHENTICATION_ANNOTATION_REQUIRED("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."), + LOGIN_REQUIRED("로그인이 필요합니다"), + MEMBER_ID_NOT_IN_SESSION("세션에 MEMBER_ID가 없습니다."), + INVALID_REQUEST("잘못된 요청입니다."), + MISSING_REQUEST_PARAMETER("필수 요청 파라미터가 누락되었습니다."), + INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다. JSON 형식을 확인해주세요."), + JSON_PARSE_ERROR("JSON 파싱 오류: 유효하지 않은 값이 전달되었습니다."), + INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다."); + + private final String message; + + ApiErrorMessage(String message) { + this.message = message; + } +} diff --git a/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 4f880ec..352305d 100644 --- a/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.samhap.kokomen.global.dto.ErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -11,7 +12,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -// TODO: HttpMessageNotReadableException 예외 처리 추가 @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @@ -25,12 +25,12 @@ public ResponseEntity handleKokomenException(KokomenException e) @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - String defaultErrorMessageForUser = "잘못된 요청입니다."; + String defaultErrorMessageForUser = ApiErrorMessage.INVALID_REQUEST.getMessage(); String message = e.getBindingResult() .getFieldErrors() .stream() .findFirst() - .map(error -> error.getDefaultMessage()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) .orElse(defaultErrorMessageForUser); if (message.equals(defaultErrorMessageForUser)) { @@ -44,35 +44,33 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho } @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { - String message = "필수 요청 파라미터 '" + e.getParameterName() + "'가 누락되었습니다."; - log.warn("MissingServletRequestParameterException :: message: {}", message); + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { + log.warn("MissingServletRequestParameterException :: parameterName: {}", e.getParameterName()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ApiErrorMessage.MISSING_REQUEST_PARAMETER.getMessage())); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - String message = "잘못된 요청 형식입니다. JSON 형식을 확인해주세요."; if (e.getCause() instanceof InvalidFormatException invalidFormatException) { String fieldName = invalidFormatException.getPath().get(0).getFieldName(); String invalidValue = String.valueOf(invalidFormatException.getValue()); - message = String.format( - "JSON 파싱 오류: '%s' 필드에 유효하지 않은 값이 전달되었습니다. (전달된 값: '%s')", - fieldName, - invalidValue - ); + log.warn("HttpMessageNotReadableException :: fieldName: {}, invalidValue: {}", fieldName, invalidValue); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(ApiErrorMessage.JSON_PARSE_ERROR.getMessage())); } - log.warn("HttpMessageNotReadableException :: message: {}", message); + log.warn("HttpMessageNotReadableException :: message: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ApiErrorMessage.INVALID_REQUEST_FORMAT.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { - log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); + log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); + .body(new ErrorResponse(ApiErrorMessage.INTERNAL_SERVER_ERROR.getMessage())); } } diff --git a/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java index 10825ed..d4af78f 100644 --- a/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java +++ b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java @@ -2,6 +2,8 @@ import com.samhap.kokomen.global.annotation.Authentication; import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.ApiErrorMessage; +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 +31,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(ApiErrorMessage.AUTHENTICATION_ANNOTATION_REQUIRED.getMessage()); } boolean authenticationRequired = authentication.required(); HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); @@ -47,16 +49,16 @@ public Object resolveArgument(MethodParameter parameter, private void validateAuthentication(HttpSession session, boolean authenticationRequired) { if (session == null && authenticationRequired) { - throw new UnauthorizedException("로그인이 필요합니다"); + throw new UnauthorizedException(ApiErrorMessage.LOGIN_REQUIRED.getMessage()); } } private void validateAuthentication(Long memberId, boolean authenticationRequired) { if (memberId == null) { - log.error("세션에 MEMBER_ID가 없습니다."); + log.error(ApiErrorMessage.MEMBER_ID_NOT_IN_SESSION.getMessage()); } if (memberId == null && authenticationRequired) { - throw new IllegalStateException("세션에 MEMBER_ID가 없습니다."); + throw new UnauthorizedException(ApiErrorMessage.MEMBER_ID_NOT_IN_SESSION.getMessage()); } } } diff --git a/common/run-test-redis.sh b/common/run-test-redis.sh index aaaeea8..ad4e473 100755 --- a/common/run-test-redis.sh +++ b/common/run-test-redis.sh @@ -1,8 +1,8 @@ #!/bin/bash if ! docker ps --format '{{.Names}}' | grep -q '^payment-test-redis$'; then - echo "payment-test-redis 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d payment-test-redis로 시작합니다..." - docker compose -f test.yml up -d payment-test-redis + echo "payment-test-redis 컨테이너가 실행 중이 아닙니다. docker compose -f test-docker-compose.yml up -d payment-test-redis로 시작합니다..." + docker compose -f "$(git rev-parse --show-toplevel)/test-docker-compose.yml" up -d payment-test-redis else echo "payment-test-redis 컨테이너가 이미 실행 중입니다." fi diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java b/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java new file mode 100644 index 0000000..fe7f356 --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java @@ -0,0 +1,12 @@ +package com.samhap.kokomen.global.exception; + +public class InternalServerErrorException extends KokomenException { + + public InternalServerErrorException(String message) { + super(message, 500); + } + + public InternalServerErrorException(String message, Throwable cause) { + super(message, cause, 500); + } +} diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java b/common/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java new file mode 100644 index 0000000..0d73b5f --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class NotFoundException extends KokomenException { + + public NotFoundException(String message) { + super(message, 404); + } +} diff --git a/common/test.yml b/common/test.yml deleted file mode 100644 index a6650ad..0000000 --- a/common/test.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - payment-test-redis: - container_name: payment-test-redis - image: valkey/valkey:8.0.1 - ports: - - 16390:6379 - volumes: - - kokomen-payment-test-redis-data:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 3s - retries: 5 - -volumes: - kokomen-payment-test-redis-data: diff --git a/docker/prod/docker-compose-prod.yml b/docker/prod/docker-compose-prod.yml index 31ec1ed..bd77ad4 100644 --- a/docker/prod/docker-compose-prod.yml +++ b/docker/prod/docker-compose-prod.yml @@ -39,6 +39,7 @@ services: SPRING_DATASOURCE_URL_PROD: ${SPRING_DATASOURCE_URL_PROD} SPRING_DATASOURCE_USERNAME_PROD: ${SPRING_DATASOURCE_USERNAME_PROD} SPRING_DATASOURCE_PASSWORD_PROD: ${SPRING_DATASOURCE_PASSWORD_PROD} + WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD} nginx: image: nginx:1.28.0 diff --git a/domain/build.gradle b/domain/build.gradle index 6d18cd0..9fb1470 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -1,4 +1,5 @@ dependencies { + implementation project(':common') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.flywaydb:flyway-core:11.9.1' implementation 'org.flywaydb:flyway-mysql:11.9.1' diff --git a/domain/run-test-mysql.sh b/domain/run-test-mysql.sh index 4e1ccdd..73f0bcf 100755 --- a/domain/run-test-mysql.sh +++ b/domain/run-test-mysql.sh @@ -1,8 +1,8 @@ #!/bin/bash if ! docker ps --format '{{.Names}}' | grep -q '^payment-test-mysql$'; then - echo "payment-test-mysql 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d payment-test-mysql로 시작합니다..." - docker compose -f test.yml up -d payment-test-mysql + echo "payment-test-mysql 컨테이너가 실행 중이 아닙니다. docker compose -f test-docker-compose.yml up -d payment-test-mysql로 시작합니다..." + docker compose -f "$(git rev-parse --show-toplevel)/test-docker-compose.yml" up -d payment-test-mysql else echo "payment-test-mysql 컨테이너가 이미 실행 중입니다." fi diff --git a/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java new file mode 100644 index 0000000..acaf60a --- /dev/null +++ b/domain/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/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java index dd8a206..2ecdb4a 100644 --- a/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java +++ b/domain/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.InternalServerErrorException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -13,7 +14,9 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -52,7 +55,8 @@ public class TosspaymentsPayment extends BaseEntity { @Enumerated(EnumType.STRING) private ServiceType serviceType; - public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, String orderName, Long totalAmount, String metadata, ServiceType serviceType) { + 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; @@ -67,18 +71,18 @@ public void updateState(PaymentState state) { this.state = state; } - public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount, String metadata) { + public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount) { if (!this.paymentKey.equals(paymentKey)) { - throw new IllegalStateException("토스 페이먼츠 응답(%s)의 paymentKey가 DB에 저장된 값(%s)과 다릅니다.".formatted(paymentKey, this.paymentKey)); + log.error("paymentKey 불일치 - 응답: {}, DB: {}", paymentKey, this.paymentKey); + throw new InternalServerErrorException(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); } if (!this.orderId.equals(orderId)) { - throw new IllegalStateException("토스 페이먼츠 응답(%s)의 orderId가 DB에 저장된 값(%s)과 다릅니다.".formatted(orderId, this.orderId)); + log.error("orderId 불일치 - 응답: {}, DB: {}", orderId, this.orderId); + throw new InternalServerErrorException(PaymentErrorMessage.ORDER_ID_MISMATCH.getMessage()); } if (!this.totalAmount.equals(totalAmount)) { - throw new IllegalStateException("토스 페이먼츠 응답(%d)의 totalAmount가 DB에 저장된 값(%d)과 다릅니다.".formatted(totalAmount, this.totalAmount)); + log.error("totalAmount 불일치 - 응답: {}, DB: {}", totalAmount, this.totalAmount); + throw new InternalServerErrorException(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); } -// if (!this.metadata.equals(metadata)) { -// throw new IllegalStateException("토스 페이먼츠 응답(%s)의 metadata가 DB에 저장된 값(%s)과 다릅니다.".formatted(metadata, this.metadata)); -// } } } diff --git a/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java b/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java index fc38fe1..89e8fc1 100644 --- a/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java +++ b/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java @@ -5,5 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TosspaymentsPaymentRepository extends JpaRepository { + Optional findByPaymentKey(String paymentKey); -} \ No newline at end of file +} diff --git a/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java new file mode 100644 index 0000000..0319b7c --- /dev/null +++ b/domain/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/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java new file mode 100644 index 0000000..bc13de3 --- /dev/null +++ b/domain/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/external/src/main/java/com/samhap/kokomen/global/exception/ExternalErrorMessage.java b/external/src/main/java/com/samhap/kokomen/global/exception/ExternalErrorMessage.java new file mode 100644 index 0000000..4846793 --- /dev/null +++ b/external/src/main/java/com/samhap/kokomen/global/exception/ExternalErrorMessage.java @@ -0,0 +1,19 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum ExternalErrorMessage { + + INVALID_REQUEST("잘못된 요청입니다."), + MISSING_REQUEST_PARAMETER("필수 요청 파라미터가 누락되었습니다."), + INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다. JSON 형식을 확인해주세요."), + JSON_PARSE_ERROR("JSON 파싱 오류: 유효하지 않은 값이 전달되었습니다."), + INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다."); + + private final String message; + + ExternalErrorMessage(String message) { + this.message = message; + } +} diff --git a/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 4f880ec..4d4b92a 100644 --- a/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.samhap.kokomen.global.dto.ErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -11,7 +12,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -// TODO: HttpMessageNotReadableException 예외 처리 추가 @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @@ -25,12 +25,12 @@ public ResponseEntity handleKokomenException(KokomenException e) @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - String defaultErrorMessageForUser = "잘못된 요청입니다."; + String defaultErrorMessageForUser = ExternalErrorMessage.INVALID_REQUEST.getMessage(); String message = e.getBindingResult() .getFieldErrors() .stream() .findFirst() - .map(error -> error.getDefaultMessage()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) .orElse(defaultErrorMessageForUser); if (message.equals(defaultErrorMessageForUser)) { @@ -44,35 +44,33 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho } @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { - String message = "필수 요청 파라미터 '" + e.getParameterName() + "'가 누락되었습니다."; - log.warn("MissingServletRequestParameterException :: message: {}", message); + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { + log.warn("MissingServletRequestParameterException :: parameterName: {}", e.getParameterName()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ExternalErrorMessage.MISSING_REQUEST_PARAMETER.getMessage())); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - String message = "잘못된 요청 형식입니다. JSON 형식을 확인해주세요."; if (e.getCause() instanceof InvalidFormatException invalidFormatException) { String fieldName = invalidFormatException.getPath().get(0).getFieldName(); String invalidValue = String.valueOf(invalidFormatException.getValue()); - message = String.format( - "JSON 파싱 오류: '%s' 필드에 유효하지 않은 값이 전달되었습니다. (전달된 값: '%s')", - fieldName, - invalidValue - ); + log.warn("HttpMessageNotReadableException :: fieldName: {}, invalidValue: {}", fieldName, invalidValue); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(ExternalErrorMessage.JSON_PARSE_ERROR.getMessage())); } - log.warn("HttpMessageNotReadableException :: message: {}", message); + log.warn("HttpMessageNotReadableException :: message: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ExternalErrorMessage.INVALID_REQUEST_FORMAT.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { - log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); + log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); + .body(new ErrorResponse(ExternalErrorMessage.INTERNAL_SERVER_ERROR.getMessage())); } } diff --git a/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java b/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java index d1ef77c..c4b05ab 100644 --- a/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java +++ b/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java @@ -36,7 +36,7 @@ public TossPaymentsClientBuilder( .defaultHeader("Authorization", "Basic " + encodedSecretKey) .defaultHeader("Content-Type", "application/json") .messageConverters(converters -> { - converters.removeIf(converter -> converter instanceof MappingJackson2HttpMessageConverter); + converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); converters.add(new MappingJackson2HttpMessageConverter(createObjectMapper())); }); } diff --git a/external/src/main/resources/application-external.yml b/external/src/main/resources/application-external.yml index 42add61..69defca 100644 --- a/external/src/main/resources/application-external.yml +++ b/external/src/main/resources/application-external.yml @@ -22,4 +22,4 @@ spring: activate: on-profile: prod tosspayments: - widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + widget-secret-key: ${WIDGET_SECRET_KEY_PROD} diff --git a/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 01fb80d..a2c246f 100644 --- a/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.dto.ErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -22,12 +23,12 @@ public ResponseEntity handleKokomenException(KokomenException e) @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - String defaultErrorMessageForUser = "잘못된 요청입니다."; + String defaultErrorMessageForUser = PaymentServiceErrorMessage.INVALID_REQUEST.getMessage(); String message = e.getBindingResult() .getFieldErrors() .stream() .findFirst() - .map(error -> error.getDefaultMessage()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) .orElse(defaultErrorMessageForUser); if (message.equals(defaultErrorMessageForUser)) { @@ -44,13 +45,13 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { log.warn("HttpMessageNotReadableException :: message: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse("잘못된 요청 형식입니다.")); + .body(new ErrorResponse(PaymentServiceErrorMessage.INVALID_REQUEST_FORMAT.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); + .body(new ErrorResponse(PaymentServiceErrorMessage.INTERNAL_SERVER_ERROR.getMessage())); } -} \ No newline at end of file +} diff --git a/internal/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java b/internal/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java new file mode 100644 index 0000000..16095e2 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java @@ -0,0 +1,23 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum PaymentServiceErrorMessage { + + 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/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index 83c98f6..12a1402 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -1,6 +1,9 @@ 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; @@ -34,7 +37,11 @@ public PaymentResponse confirmPayment(ConfirmRequest request) { try { TosspaymentsPaymentResponse tosspaymentsPaymentResponse = confirmPayment(request, tosspaymentsPayment); return PaymentResponse.from(tosspaymentsPaymentResponse); - } catch (Exception e) { + } catch (KokomenException | HttpServerErrorException | ResourceAccessException e) { + // inner에서 상태 처리 완료 + throw e; + } catch (Exception e) { + // 예상치 못한 예외만 NEED_CANCEL 설정 tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); throw e; } @@ -44,55 +51,91 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp try { TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest()); tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), tosspaymentsConfirmResponse.orderId(), - tosspaymentsConfirmResponse.totalAmount(), tosspaymentsConfirmResponse.metadata()); + tosspaymentsConfirmResponse.totalAmount()); TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.COMPLETED); return tosspaymentsConfirmResponse; } catch (HttpClientErrorException e) { - // 토스에서 400 에러를 응답하면 TosspaymentsPaymentResponse 스펙이 아니라 {"code": ..., "message": ...} 스펙으로만 응답이 온다. - Failure failure = e.getResponseBodyAs(Failure.class); - String code = failure.code(); - log.info("토스 결제 실패(400), code = " + code + ", message = " + failure.message()); - // TODO: 기존 로직 제대로 돌아가도록 반영 -// TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class); -// String code = tosspaymentsConfirmResponse.failure().code(); -// if (TosspaymentsInternalServerErrorCode.contains(code)) { -// TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); -// tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.SERVER_BAD_REQUEST); -// throw e; -// } -// -// tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CLIENT_BAD_REQUEST); -// throw new BadRequestException(tosspaymentsConfirmResponse.failure().message(), e); - throw new BadRequestException(failure.message(), e); + throw handleConfirmClientError(e, tosspaymentsPayment); } catch (HttpServerErrorException e) { - // TODO: retry + 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 (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) { + // TODO: retry + try { TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class); TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.NEED_CANCEL); - throw e; - } catch (ResourceAccessException e) { - if (e.getRootCause() instanceof SocketTimeoutException) { - SocketTimeoutException socketTimeoutException = (SocketTimeoutException) e.getRootCause(); - if (socketTimeoutException.getMessage().contains("Connect timed out")) { - // TODO: retry - tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT); - throw e; - } - if (socketTimeoutException.getMessage().contains("Read timed out")) { - // TODO: retry - tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); - throw e; - } - } + } catch (Exception parseException) { + log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } + } - throw e; + private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPayment tosspaymentsPayment) { + if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) { + if (socketTimeoutException.getMessage().contains("Connect timed out")) { + // TODO: retry + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT); + return; + } + if (socketTimeoutException.getMessage().contains("Read timed out")) { + // TODO: retry + 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()); - TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), tosspaymentsPaymentCancelRequest); - tosspaymentsTransactionService.applyCancelResult(response); + 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/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java index 6e90502..d9ad13f 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java @@ -1,11 +1,15 @@ 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 { @@ -20,6 +24,9 @@ public TosspaymentsPaymentResult save(TosspaymentsPaymentResult tosspaymentsPaym @Transactional(readOnly = true) public TosspaymentsPaymentResult readByTosspaymentsPaymentId(Long tosspaymentsPaymentId) { return tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(tosspaymentsPaymentId) - .orElseThrow(() -> new IllegalStateException("해당 결제의 결과 정보가 존재하지 않습니다. tosspaymentsPaymentId: " + tosspaymentsPaymentId)); + .orElseThrow(() -> { + log.error("결제 결과 조회 실패 - tosspaymentsPaymentId: {}", tosspaymentsPaymentId); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_RESULT_NOT_FOUND.getMessage()); + }); } } diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java index c4d7259..af17691 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java @@ -1,13 +1,17 @@ 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.Transactional; +@Slf4j @RequiredArgsConstructor @Service public class TosspaymentsPaymentService { @@ -22,13 +26,19 @@ public TosspaymentsPayment saveTosspaymentsPayment(ConfirmRequest request) { @Transactional(readOnly = true) public TosspaymentsPayment readById(Long id) { return tosspaymentsPaymentRepository.findById(id) - .orElseThrow(() -> new IllegalStateException("해당 id의 결제 정보가 존재하지 않습니다. id: " + 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(() -> new IllegalStateException("해당 paymentKey의 결제 정보가 존재하지 않습니다. paymentKey: " + paymentKey)); + .orElseThrow(() -> { + log.error("결제 정보 조회 실패 - paymentKey: {}", paymentKey); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_NOT_FOUND_BY_PAYMENT_KEY.getMessage()); + }); } @Transactional diff --git a/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java new file mode 100644 index 0000000..c2cde92 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java @@ -0,0 +1,66 @@ +package com.samhap.kokomen.global.fixture; + +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/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java new file mode 100644 index 0000000..966f476 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java @@ -0,0 +1,106 @@ +package com.samhap.kokomen.global.fixture; + +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/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java b/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java index 3e66306..3794906 100644 --- a/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java +++ b/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java @@ -25,7 +25,6 @@ class PaymentControllerTest extends BaseControllerTest { @Test void 결제를_승인한다() throws Exception { - // given PaymentResponse mockResponse = new PaymentResponse( "test_payment_key_001", PaymentType.NORMAL, @@ -71,7 +70,6 @@ class PaymentControllerTest extends BaseControllerTest { } """; - // when & then mockMvc.perform(post("/internal/v1/payments/confirm") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) @@ -80,7 +78,6 @@ class PaymentControllerTest extends BaseControllerTest { @Test void 결제를_취소한다() throws Exception { - // given doNothing().when(paymentFacadeService).cancelPayment(any(CancelRequest.class)); String requestJson = """ @@ -90,7 +87,6 @@ class PaymentControllerTest extends BaseControllerTest { } """; - // when & then mockMvc.perform(post("/internal/v1/payments/cancel") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) diff --git a/internal/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java b/internal/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java new file mode 100644 index 0000000..b2a5e72 --- /dev/null +++ b/internal/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.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/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java new file mode 100644 index 0000000..079a686 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -0,0 +1,302 @@ +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.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.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.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.TosspaymentsClient; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +class PaymentFacadeServiceTest extends BaseTest { + + @MockitoBean + private TosspaymentsClient tosspaymentsClient; + + @Autowired + private PaymentFacadeService paymentFacadeService; + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Autowired + private TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Test + void 결제_승인에_성공한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(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.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_API_KEY", "잘못된 API 키입니다.")); + when(tosspaymentsClient.confirmPayment(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.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); + when(tosspaymentsClient.confirmPayment(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())).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())).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())) + .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())) + .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())) + .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.getResponseBodyAs(Failure.class)).thenReturn(null); + when(tosspaymentsClient.confirmPayment(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())).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 결제_취소에_성공한다() { + 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/internal/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java new file mode 100644 index 0000000..2415d7b --- /dev/null +++ b/internal/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.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.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/domain/test.yml b/test-docker-compose.yml similarity index 62% rename from domain/test.yml rename to test-docker-compose.yml index 112aa58..37838e1 100644 --- a/domain/test.yml +++ b/test-docker-compose.yml @@ -21,3 +21,19 @@ services: interval: 5s timeout: 3s retries: 5 + + payment-test-redis: + container_name: payment-test-redis + image: valkey/valkey:8.0.1 + ports: + - 16390:6379 + volumes: + - kokomen-payment-test-redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + kokomen-payment-test-redis-data: