diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..495687b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4de8f1a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Kokomen Payment (꼬꼬면 결제) is a multi-module Spring Boot payment system implementing Tosspayments integration. The project uses Java 17, Spring Boot 3.4.5, MySQL, Redis, and follows a domain-driven design with layered architecture. + +## Module Structure + +- **api**: External-facing REST API module with authentication and member services +- **internal**: Internal payment processing module with Tosspayments integration +- **domain**: Core domain entities and repositories (JPA/Hibernate) +- **common**: Shared configurations (Redis, logging) +- **external**: External API clients (Tosspayments) + +## Common Commands + +### Build & Test +```bash +# Run all tests +./gradlew test + +# Build entire project +./gradlew clean build + +# Build specific module +./gradlew :api:build +./gradlew :internal:build + +# Run tests for specific module +./gradlew :api:test +./gradlew :internal:test +``` + +### Local Development +```bash +# Start API service locally (includes MySQL & Redis) +cd api && ./run-local-api.sh + +# Start Internal service locally +cd internal && ./run-local-internal.sh + +# Start test MySQL container +cd domain && ./run-test-mysql.sh + +# Start test Redis container +cd common && ./run-test-redis.sh +``` + +## Architecture & Key Patterns + +### Package Structure +Domain-first organization followed by layered architecture: +``` +domain/ +├── controller/ +├── service/ +├── repository/ +├── domain/ +└── dto/ +global/ +├── config/ +├── exception/ +└── infrastructure/ +``` + +### Testing Strategy +- **Integration tests preferred**: Uses real beans with MySQL test containers (not H2) +- **DatabaseCleaner pattern**: Tests use `MySQLDatabaseCleaner` instead of `@Transactional` for proper isolation +- **Base test classes**: `BaseTest` for services, `BaseControllerTest` for controllers with MockMvc +- **Test naming**: Korean method names without `@DisplayName` + +### Payment Flow +1. **PaymentFacadeService**: Orchestrates payment confirmation flow +2. **TosspaymentsTransactionService**: Manages transactional operations +3. **TosspaymentsPaymentService**: Handles payment entity operations +4. **TosspaymentsClient**: External API communication with Tosspayments + +### Exception Handling +Custom exceptions with HTTP status mapping: +- `BadRequestException` (400) +- `UnauthorizedException` (401) +- `ForbiddenException` (403) + +Global exception handler in `GlobalExceptionHandler` class. + +### Pagination +- **Always use Spring Data's `Pageable`** for pagination +- Controller methods receive `Pageable` as parameter with `@PageableDefault` annotation +- Example: `@PageableDefault(size = 10) Pageable pageable` +- Service methods accept `Pageable` directly without decomposing into page/size +- Repository methods use `Pageable` for database queries + +## Code Style & Conventions + +### Java Style +- Based on Woowacourse Java Style Guide (Google Java Style variant) +- **Indentation**: 4 spaces +- **Column limit**: 120 characters (general), 160 (maximum) +- **Line wrapping**: +8 spaces for continuation lines + +### Method Parameter Rules +- **Records/Controllers**: One parameter per line +- **Annotated methods or >160 chars**: One per line +- **Regular methods**: No line breaks + +### Naming Conventions +- **Methods**: action + domain format (e.g., `saveMember()`) +- **Read vs Find**: `read` throws exception if not found, `find` returns Optional/empty +- **No `get-` prefix** except for actual getters +- **No `not` in validation methods** +- **No `final` in method parameters** + +### Lombok Usage +- Use for constructors, getters/setters +- Spring annotations before Lombok annotations +- Domain entities override `toString()` + +### DTO-Entity Conversion +- **DTO → Entity**: Conversion methods are placed in DTO classes (e.g., `dto.toEntity()`) +- **Entity → DTO**: Conversion methods are also placed in DTO classes as static factory methods or in service layer +- DTOs are responsible for transformation logic to keep entities clean +- Example: `ConfirmRequest.toTosspaymentsPayment()` in DTO class + +### Transaction Management +- **@Transactional always on methods, not classes** +- Place `@Transactional(readOnly = true)` on read-only service methods +- Place `@Transactional` on write service methods +- Never put @Transactional at class level + +### Testing Conventions +- Test methods named in Korean +- No `@DisplayName` annotations +- Given-When-Then structure +- Test data initialized in given section (no data.sql) + +## Database & Persistence + +### Flyway Migrations +Location: `domain/src/main/resources/db/migration/` +Naming: `V{version}__{description}.sql` + +### DDL Conventions +- **ENUM columns**: Always use MySQL ENUM type instead of VARCHAR for enum fields + - Example: `ALTER TABLE table_name ADD COLUMN status ENUM('ACTIVE', 'INACTIVE') NOT NULL` + - This ensures type safety at the database level and reduces storage size + +### Test Containers +MySQL 8.4.5 containers for testing (port 13308) +Configuration in `domain/test.yml` + +## Commit Message Convention + +Follow Angular commit message convention: +- **feat**: A new feature for the user +- **fix**: A bug fix +- **docs**: Documentation changes +- **style**: Changes that don't affect code meaning (formatting, etc.) +- **refactor**: Code changes that neither fix bugs nor add features +- **test**: Adding or updating tests +- **chore**: Changes to build process, tools, dependencies + +Format: `: ` +Example: `feat: 결제 기능 개발` + +## Important Notes + +1. **No wildcard imports** in Java files +2. **MDC logging context** must be preserved across threads +3. **Security**: Never log or commit secrets/keys +4. **Git commits**: Never commit unless explicitly requested +5. **Documentation**: Don't create README/docs unless requested +6. **Testing**: Always verify with existing test commands before suggesting new ones \ No newline at end of file diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc index 1bb3d4d..cebb872 100644 --- a/api/src/docs/asciidoc/index.adoc +++ b/api/src/docs/asciidoc/index.adoc @@ -1,4 +1,4 @@ -= Kokomen API Guide += Kokomen Payment API Guide :doctype: book :icons: font :toc: left @@ -10,10 +10,12 @@ == 결제 -=== 결제 XXX +=== 나의 결제 내역 조회 -include::{snippetsDir}/payment-xxx/http-request.adoc[] -include::{snippetsDir}/payment-xxx/http-response.adoc[] -include::{snippetsDir}/payment-xxx/request-fields.adoc[] -include::{snippetsDir}/payment-xxx/curl-request.adoc[] +include::{snippetsDir}/payment-findMyPayments/http-request.adoc[] +include::{snippetsDir}/payment-findMyPayments/query-parameters.adoc[] +include::{snippetsDir}/payment-findMyPayments/request-cookies.adoc[] +include::{snippetsDir}/payment-findMyPayments/http-response.adoc[] +include::{snippetsDir}/payment-findMyPayments/response-fields.adoc[] +include::{snippetsDir}/payment-findMyPayments/curl-request.adoc[] 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 1173136..4f880ec 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 @@ -1,10 +1,13 @@ package com.samhap.kokomen.global.exception; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.samhap.kokomen.global.dto.ErrorResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -40,6 +43,32 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho .body(new ErrorResponse(message)); } + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + String message = "필수 요청 파라미터 '" + e.getParameterName() + "'가 누락되었습니다."; + log.warn("MissingServletRequestParameterException :: message: {}", message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(message)); + } + + @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 :: message: {}", message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(message)); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); diff --git a/api/src/main/java/com/samhap/kokomen/payment/dto/MyPaymentResponse.java b/api/src/main/java/com/samhap/kokomen/payment/dto/MyPaymentResponse.java new file mode 100644 index 0000000..40a4f54 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/payment/dto/MyPaymentResponse.java @@ -0,0 +1,42 @@ +package com.samhap.kokomen.payment.dto; + +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import java.time.LocalDateTime; + +public record MyPaymentResponse( + String paymentKey, + String orderId, + String orderName, + Long totalAmount, + PaymentState state, + ServiceType serviceType, + LocalDateTime createdAt, + LocalDateTime approvedAt, + String method, + String metadata, + String failureCode, + String failureMessage +) { + + public static MyPaymentResponse from(TosspaymentsPaymentResult result) { + TosspaymentsPayment payment = result.getTosspaymentsPayment(); + + return new MyPaymentResponse( + payment.getPaymentKey(), + payment.getOrderId(), + payment.getOrderName(), + payment.getTotalAmount(), + payment.getState(), + payment.getServiceType(), + payment.getCreatedAt(), + result.getApprovedAt(), + result.getMethod(), + payment.getMetadata(), + result.getFailureCode(), + result.getFailureMessage() + ); + } +} diff --git a/api/src/test/java/com/samhap/kokomen/global/BaseTest.java b/api/src/test/java/com/samhap/kokomen/global/BaseTest.java index 739137d..33da7de 100644 --- a/api/src/test/java/com/samhap/kokomen/global/BaseTest.java +++ b/api/src/test/java/com/samhap/kokomen/global/BaseTest.java @@ -1,16 +1,20 @@ package com.samhap.kokomen.global; - import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @ActiveProfiles("test") @SpringBootTest(webEnvironment = WebEnvironment.MOCK) public abstract class BaseTest { + @MockitoSpyBean + protected RedisTemplate redisTemplate; + @Autowired private MySQLDatabaseCleaner mySQLDatabaseCleaner; diff --git a/api/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java b/api/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java new file mode 100644 index 0000000..fdedf7d --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java @@ -0,0 +1,67 @@ +package com.samhap.kokomen.global.fixture; + +import com.samhap.kokomen.payment.domain.PaymentState; +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 + ); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java b/api/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java new file mode 100644 index 0000000..c832824 --- /dev/null +++ b/api/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 + ); + } +} \ No newline at end of file diff --git a/common/build.gradle b/common/build.gradle index e6d9b47..f3fb00c 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,6 +1,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'io.micrometer:micrometer-registry-prometheus' } diff --git a/common/src/main/java/com/samhap/kokomen/global/annotation/ExecutionTimer.java b/common/src/main/java/com/samhap/kokomen/global/annotation/ExecutionTimer.java new file mode 100644 index 0000000..ae90905 --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/annotation/ExecutionTimer.java @@ -0,0 +1,11 @@ +package com.samhap.kokomen.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExecutionTimer { +} diff --git a/common/src/main/java/com/samhap/kokomen/global/aop/ExecutionTimerAspect.java b/common/src/main/java/com/samhap/kokomen/global/aop/ExecutionTimerAspect.java new file mode 100644 index 0000000..8ed64d4 --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/aop/ExecutionTimerAspect.java @@ -0,0 +1,33 @@ +package com.samhap.kokomen.global.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +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(2) +@Aspect +@Component +public class ExecutionTimerAspect { + + @Around("@within(com.samhap.kokomen.global.annotation.ExecutionTimer) || @annotation(com.samhap.kokomen.global.annotation.ExecutionTimer)") + public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + Object result = joinPoint.proceed(); + stopWatch.stop(); + logExecutionTime(joinPoint.getSignature(), stopWatch.getTotalTimeMillis()); + return result; + } + + private static void logExecutionTime(Signature signature, long executionTime) { + String className = signature.getDeclaringTypeName(); + String methodName = signature.getName(); + log.info("Execution time : {}.{} - {}ms", className, methodName, executionTime); + } +} diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java b/common/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java index a21e8aa..f51cf59 100644 --- a/common/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java +++ b/common/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java @@ -5,4 +5,8 @@ public class BadRequestException extends KokomenException { public BadRequestException(String message) { super(message, 400); } + + public BadRequestException(String message, Throwable cause) { + super(message, cause, 400); + } } diff --git a/docs/convention.md b/docs/convention.md new file mode 100644 index 0000000..31fbe36 --- /dev/null +++ b/docs/convention.md @@ -0,0 +1,1213 @@ +> 코드의 상세한 스타일 +> 컨벤션은 [우아한테크코스 Java Style Guide](https://github.com/woowacourse/woowacourse-docs/blob/main/styleguide/java/README.md)를 +> 따른다. +> + +- 우아한테크코스 자바 스타일 가이드 상세 내용 + + **Java Style Guide** + + 우아한테크코스의 자바 스타일 가이드는[Google Java Style Guide](https://google.github.io/styleguide/javaguide.html)를 기준으로 작성되었습니다. + + Google Java Style Guide와 다른 부분만 아래 명시합니다. + + --- + + **4.2 블럭 들여쓰기: +4 스페이스** + + 새 블록 또는 블록과 유사한 구조(block-like construct)가 열릴 때마다 들여쓰기가 네 칸씩 증가합니다. 블록이 끝나면 들여쓰기는 이전 들여쓰기 단계로 돌아갑니다. 들여쓰기 단계는 블록 전체의 + 코드와 주석 모두에 적용됩니다. + + **4.4 열 제한: 120** + + Java 코드의 열 제한은 120자입니다. "문자"는 유니코드 코드 포인트를 의미합니다. + + **4.5.2 들여쓰기 지속은 최소 +8 스페이스** + + 줄 바꿈 시 그 다음 줄은 원래 줄에서 +8 이상 들여씁니다. + + **4.6.1 수직 빈 줄** + + ... + + 빈 줄은 가독성을 향상시키기 위해서라면 어디든(예를 들면 논리적으로 코드를 구분하기 위해 문장 사이) 사용 될 수 있습니다. 클래스의 첫 번째 멤버나 초기화(initializer) 또는 마지막 멤버 또는 초기화( + initializer) 뒤의 빈 줄은 권장되지도 비권장하지도 않습니다. + + > 클래스의 첫 번째 멤버나 초기화(initializer) 앞에 있는 빈줄을 강제하지 않습니다. + > + + --- + + # 구글 자바 스타일 가이드 + + ## **1 Introduction** + + ![](https://google.github.io/styleguide/include/link.png) + + This document serves as the**complete**definition of Google's coding standards for source code in the Java™ + Programming Language. A Java source file is described as being*in Google Style*if and only if it adheres to the rules + herein. + + Like other programming style guides, the issues covered span not only aesthetic issues of formatting, but other types + of conventions or coding standards as well. However, this document focuses primarily on the**hard-and-fast rules**that + we follow universally, and avoids giving*advice*that isn't clearly enforceable (whether by human or tool). + + ### **1.1 Terminology notes** + + ![](https://google.github.io/styleguide/include/link.png) + + In this document, unless otherwise clarified: + + 1. The term*class*is used inclusively to mean an "ordinary" class, record class, enum class, interface or annotation + type (`@interface`). + 2. The term*member*(of a class) is used inclusively to mean a nested class, field, method,*or constructor*; that is, + all top-level contents of a class except initializers and comments. + 3. The term*comment*always refers to*implementation*comments. We do not use the phrase "documentation comments", and + instead use the common term "Javadoc." + + Other "terminology notes" will appear occasionally throughout the document. + + ### **1.2 Guide notes** + + ![](https://google.github.io/styleguide/include/link.png) + + Example code in this document is**non-normative**. That is, while the examples are in Google Style, they may not + illustrate the*only*stylish way to represent the code. Optional formatting choices made in examples should not be + enforced as rules. + + ## **2 Source file basics** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **2.1 File name** + + ![](https://google.github.io/styleguide/include/link.png) + + For a source file containing classes, the file name consists of the case-sensitive name of the top-level class (of + which there is[exactly one](https://google.github.io/styleguide/javaguide.html#s3.4.1-one-top-level-class)), plus the + `.java`extension. + + ### **2.2 File encoding: UTF-8** + + ![](https://google.github.io/styleguide/include/link.png) + + Source files are encoded in**UTF-8**. + + ### **2.3 Special characters** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **2.3.1 Whitespace characters** + + ![](https://google.github.io/styleguide/include/link.png) + + Aside from the line terminator sequence, the**ASCII horizontal space character**(**0x20**) is the only whitespace + character that appears anywhere in a source file. This implies that: + + 1. All other whitespace characters in string and character literals are escaped. + 2. Tab characters are**not**used for indentation. + + ### **2.3.2 Special escape sequences** + + ![](https://google.github.io/styleguide/include/link.png) + + For any character that has + a[special escape sequence](http://docs.oracle.com/javase/tutorial/java/data/characters.html)(`\b`,`\t`,`\n`,`\f`,`\r`, + `\s`,`\"`,`\'`and`\\`), that sequence is used rather than the corresponding octal (e.g.`\012`) or Unicode (e.g. + `\u000a`) escape. + + ### **2.3.3 Non-ASCII characters** + + ![](https://google.github.io/styleguide/include/link.png) + + For the remaining non-ASCII characters, either the actual Unicode character (e.g.`∞`) or the equivalent Unicode + escape (e.g.`\u221e`) is used. The choice depends only on which makes the code**easier to read and understand**, + although Unicode escapes outside string literals and comments are strongly discouraged. + + **Tip:**In the Unicode escape case, and occasionally even when actual Unicode characters are used, an explanatory + comment can be very helpful. + + Examples: + + | **Example** | **Discussion** | + | --- | --- | + | `String unitAbbrev = "μs";` | Best: perfectly clear even without a comment. | + | `String unitAbbrev = "\u03bcs"; // "μs"` | Allowed, but there's no reason to do this. | + | `String unitAbbrev = "\u03bcs"; // Greek letter mu, "s"` | Allowed, but awkward and prone to mistakes. | + | `String unitAbbrev = "\u03bcs";` | Poor: the reader has no idea what this is. | + | `return '\ufeff' + content; // byte order mark` | Good: use escapes for non-printable characters, and comment if necessary. | + + **Tip:**Never make your code less readable simply out of fear that some programs might not handle non-ASCII characters + properly. If that should happen, those programs are**broken**and they must be**fixed**. + + ## **3 Source file structure** + + ![](https://google.github.io/styleguide/include/link.png) + + An ordinary source file consists of,**in order**: + + 1. License or copyright information, if present + 2. Package statement + 3. Import statements + 4. Exactly one top-level class + + **Exactly one blank line**separates each section that is present. + + A`package-info.java`file is the same, but without the top-level class. + + A`module-info.java`file does not contain a package statement and replaces the single top-level class with a module + declaration, but otherwise follows the same structure. + + ### **3.1 License or copyright information, if present** + + ![](https://google.github.io/styleguide/include/link.png) + + If license or copyright information belongs in a file, it belongs here. + + ### **3.2 Package statement** + + ![](https://google.github.io/styleguide/include/link.png) + + The package statement is**not line-wrapped**. The column limit (Section + 4.4,[Column limit: 100](https://google.github.io/styleguide/javaguide.html#s4.4-column-limit)) does not apply to + package statements. + + ### **3.3 Import statements** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **3.3.1 No wildcard imports** + + ![](https://google.github.io/styleguide/include/link.png) + + **Wildcard imports**, static or otherwise,**are not used**. + + ### **3.3.2 No line-wrapping** + + ![](https://google.github.io/styleguide/include/link.png) + + Import statements are**not line-wrapped**. The column limit (Section + 4.4,[Column limit: 100](https://google.github.io/styleguide/javaguide.html#s4.4-column-limit)) does not apply to + import statements. + + ### **3.3.3 Ordering and spacing** + + ![](https://google.github.io/styleguide/include/link.png) + + Imports are ordered as follows: + + 1. All static imports in a single block. + 2. All non-static imports in a single block. + + If there are both static and non-static imports, a single blank line separates the two blocks. There are no other + blank lines between import statements. + + Within each block the imported names appear in ASCII sort order. (**Note:**this is not the same as the import + *statements*being in ASCII sort order, since '.' sorts before ';'.) + + ### **3.3.4 No static import for classes** + + ![](https://google.github.io/styleguide/include/link.png) + + Static import is not used for static nested classes. They are imported with normal imports. + + ### **3.4 Class declaration** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **3.4.1 Exactly one top-level class declaration** + + ![](https://google.github.io/styleguide/include/link.png) + + Each top-level class resides in a source file of its own. + + ### **3.4.2 Ordering of class contents** + + ![](https://google.github.io/styleguide/include/link.png) + + The order you choose for the members and initializers of your class can have a great effect on learnability. However, + there's no single correct recipe for how to do it; different classes may order their contents in different ways. + + What is important is that each class uses***some*logical order**, which its maintainer could explain if asked. For + example, new methods are not just habitually added to the end of the class, as that would yield "chronological by date + added" ordering, which is not a logical ordering. + + ### **3.4.2.1 Overloads: never split** + + Methods of a class that share the same name appear in a single contiguous group with no other members in between. The + same applies to multiple constructors (which always have the same name). This rule applies even when modifiers such as + `static`or`private`differ between the methods. + + ### **3.5 Module declaration** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **3.5.1 Ordering and spacing of module directives** + + ![](https://google.github.io/styleguide/include/link.png) + + Module directives are ordered as follows: + + 1. All`requires`directives in a single block. + 2. All`exports`directives in a single block. + 3. All`opens`directives in a single block. + 4. All`uses`directives in a single block. + 5. All`provides`directives in a single block. + + A single blank line separates each block that is present. + + ## **4 Formatting** + + ![](https://google.github.io/styleguide/include/link.png) + + **Terminology Note:***block-like construct*refers to the body of a class, method or constructor. Note that, by Section + 4.8.3.1 on[array initializers](https://google.github.io/styleguide/javaguide.html#s4.8.3.1-array-initializers), any + array initializer*may*optionally be treated as if it were a block-like construct. + + ### **4.1 Braces** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **4.1.1 Use of optional braces** + + ![](https://google.github.io/styleguide/include/link.png) + + Braces are used with`if`,`else`,`for`,`do`and`while`statements, even when the body is empty or contains only a single + statement. + + Other optional braces, such as those in a lambda expression, remain optional. + + ### **4.1.2 Nonempty blocks: K & R style** + + ![](https://google.github.io/styleguide/include/link.png) + + Braces follow the Kernighan and Ritchie + style ("[Egyptian brackets](https://blog.codinghorror.com/new-programming-jargon/#3)") for*nonempty*blocks and + block-like constructs: + + - No line break before the opening brace, except as detailed below. + - Line break after the opening brace. + - Line break before the closing brace. + - Line break after the closing brace,*only if*that brace terminates a statement or terminates the body of a method, + constructor, or*named*class. For example, there is*no*line break after the brace if it is followed by`else`or a + comma. + + Exception: In places where these rules allow a single statement ending with a semicolon (`;`), a block of statements + can appear, and the opening brace of this block is preceded by a line break. Blocks like these are typically + introduced to limit the scope of local variables. + + Examples: + + ``` + return () -> { + while (condition()) { + method(); + } + }; + + return new MyClass() { + @Override public void method() { + if (condition()) { + try { + something(); + } catch (ProblemException e) { + recover(); + } + } else if (otherCondition()) { + somethingElse(); + } else { + lastThing(); + } + { + int x = foo(); + frob(x); + } + } + }; + ``` + + A few exceptions for enum classes are given in Section + 4.8.1,[Enum classes](https://google.github.io/styleguide/javaguide.html#s4.8.1-enum-classes). + + ### **4.1.3 Empty blocks: may be concise** + + ![](https://google.github.io/styleguide/include/link.png) + + An empty block or block-like construct may be in K & R style (as described + in[Section 4.1.2](https://google.github.io/styleguide/javaguide.html#s4.1.2-blocks-k-r-style)). Alternatively, it may + be closed immediately after it is opened, with no characters or line break in between (`{}`),**unless**it is part of a + *multi-block statement*(one that directly contains multiple blocks:`if/else`or`try/catch/finally`). + + Examples: + + ``` + // This is acceptable + void doNothing() {} + + // This is equally acceptable + void doNothingElse() { + } + ``` + + ``` + // This is not acceptable: No concise empty blocks in a multi-block statement + try { + doSomething(); + } catch (Exception e) {} + ``` + + ### **4.2 Block indentation: +2 spaces** + + ![](https://google.github.io/styleguide/include/link.png) + + Each time a new block or block-like construct is opened, the indent increases by two spaces. When the block ends, the + indent returns to the previous indent level. The indent level applies to both code and comments throughout the + block. (See the example in Section + 4.1.2,[Nonempty blocks: K & R Style](https://google.github.io/styleguide/javaguide.html#s4.1.2-blocks-k-r-style).) + + ### **4.3 One statement per line** + + ![](https://google.github.io/styleguide/include/link.png) + + Each statement is followed by a line break. + + ### **4.4 Column limit: 100** + + ![](https://google.github.io/styleguide/include/link.png) + + Java code has a column limit of 100 characters. A "character" means any Unicode code point. Except as noted below, any + line that would exceed this limit must be line-wrapped, as explained in Section + 4.5,[Line-wrapping](https://google.github.io/styleguide/javaguide.html#s4.5-line-wrapping). + + Each Unicode code point counts as one character, even if its display width is greater or less. For example, if + using[fullwidth characters](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms), you may choose to wrap the + line earlier than where this rule strictly requires. + + **Exceptions:** + + 1. Lines where obeying the column limit is not possible (for example, a long URL in Javadoc, or a long JSNI method + reference). + 2. `package`and`import`statements (see Sections + 3.2[Package statement](https://google.github.io/styleguide/javaguide.html#s3.2-package-statement)and + 3.3[Import statements](https://google.github.io/styleguide/javaguide.html#s3.3-import-statements)). + 3. Contents of[text blocks](https://google.github.io/styleguide/s4.8.9-text-blocks). + 4. Command lines in a comment that may be copied-and-pasted into a shell. + 5. Very long identifiers, on the rare occasions they are called for, are allowed to exceed the column limit. In that + case, the valid wrapping for the surrounding code is as produced + by[google-java-format](https://github.com/google/google-java-format). + + ### **4.5 Line-wrapping** + + ![](https://google.github.io/styleguide/include/link.png) + + **Terminology Note:**When code that might otherwise legally occupy a single line is divided into multiple lines, this + activity is called*line-wrapping*. + + There is no comprehensive, deterministic formula showing*exactly*how to line-wrap in every situation. Very often there + are several valid ways to line-wrap the same piece of code. + + **Note:**While the typical reason for line-wrapping is to avoid overflowing the column limit, even code that would in + fact fit within the column limit*may*be line-wrapped at the author's discretion. + + **Tip:**Extracting a method or local variable may solve the problem without the need to line-wrap. + + ### **4.5.1 Where to break** + + ![](https://google.github.io/styleguide/include/link.png) + + The prime directive of line-wrapping is: prefer to break at a**higher syntactic level**. Also: + + 1. When a line is broken at a*non-assignment*operator the break comes*before*the symbol. (Note that this is not the + same practice used in Google style for other languages, such as C++ and JavaScript.) + - This also applies to the following "operator-like" symbols: + - the dot separator (`.`) + - the two colons of a method reference (`::`) + - an ampersand in a type bound (``) + - a pipe in a catch block (`catch (FooException | BarException e)`). + 2. When a line is broken at an*assignment*operator the break typically comes*after*the symbol, but either way is + acceptable. + - This also applies to the "assignment-operator-like" colon in an enhanced`for`("foreach") statement. + 3. A method, constructor, or record-class name stays attached to the open parenthesis (`(`) that follows it. + 4. A comma (`,`) stays attached to the token that precedes it. + 5. A line is never broken adjacent to the arrow in a lambda or a switch rule, except that a break may come + immediately after the arrow if the text following it consists of a single unbraced expression. Examples: + + ``` + MyLambda lambda = + (String label, Long value, Object obj) -> { + ... + }; + + Predicate predicate = str -> + longExpressionInvolving(str); + + switch (x) { + case ColorPoint(Color color, Point(int x, int y)) -> + handleColorPoint(color, x, y); + ... + } + ``` + + **Note:** The primary goal for line wrapping is to have clear code, *not necessarily* code that fits in the smallest number of lines. + + ### **4.5.2 Indent continuation lines at least +4 spaces** + + ![](https://google.github.io/styleguide/include/link.png) + + When line-wrapping, each line after the first (each *continuation line*) is indented at least +4 from the original line. + + When there are multiple continuation lines, indentation may be varied beyond +4 as desired. In general, two continuation lines use the same indentation level if and only if they begin with syntactically parallel elements. + + Section 4.6.3 on [Horizontal alignment](https://google.github.io/styleguide/javaguide.html#s4.6.3-horizontal-alignment) addresses the discouraged practice of using a variable number of spaces to align certain tokens with previous lines. + + ### **4.6 Whitespace** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **4.6.1 Vertical Whitespace** + + ![](https://google.github.io/styleguide/include/link.png) + + A single blank line always appears: + + 1. *Between* consecutive members or initializers of a class: fields, constructors, methods, nested classes, static initializers, and instance initializers. + - **Exception:** A blank line between two consecutive fields (having no other code between them) is optional. Such blank lines are used as needed to create *logical groupings* of fields. + - **Exception:** Blank lines between enum constants are covered in [Section 4.8.1](https://google.github.io/styleguide/javaguide.html#s4.8.1-enum-classes). + 2. As required by other sections of this document (such as Section 3, [Source file structure](https://google.github.io/styleguide/javaguide.html#s3-source-file-structure), and Section 3.3, [Import statements](https://google.github.io/styleguide/javaguide.html#s3.3-import-statements)). + + A single blank line may also appear anywhere it improves readability, for example between statements to organize the code into logical subsections. A blank line before the first member or initializer, or after the last member or initializer of the class, is neither encouraged nor discouraged. + + *Multiple* consecutive blank lines are permitted, but never required (or encouraged). + + ### **4.6.2 Horizontal whitespace** + + ![](https://google.github.io/styleguide/include/link.png) + + Beyond where required by the language or other style rules, and apart from literals, comments and Javadoc, a single ASCII space also appears in the following places **only**. + + 1. Separating any reserved word, such as `if`, `for` or `catch`, from an open parenthesis (`(`) that follows it on that line + 2. Separating any reserved word, such as `else` or `catch`, from a closing curly brace (`}`) that precedes it on that line + 3. Before any open curly brace (`{`), with two exceptions: + - `@SomeAnnotation({a, b})` (no space is used) + - `String[][] x = {{"foo"}};` (no space is required between `{{`, by item 9 below) + 4. On both sides of any binary or ternary operator. This also applies to the following "operator-like" symbols:but not + - the ampersand in a conjunctive type bound: `` + - the pipe for a catch block that handles multiple exceptions: `catch (FooException | BarException e)` + - the colon (`:`) in an enhanced `for` ("foreach") statement + - the arrow in a lambda expression: `(String str) -> str.length()`or switch rule: `case "FOO" -> bar();` + - the two colons (`::`) of a method reference, which is written like `Object::toString` + - the dot separator (`.`), which is written like `object.toString()` + 5. After `,:;` or the closing parenthesis (`)`) of a cast + 6. Between any content and a double slash (`//`) which begins a comment. Multiple spaces are allowed. + 7. Between a double slash (`//`) which begins a comment and the comment's text. Multiple spaces are allowed. + 8. Between the type and variable of a declaration: `List list` + 9. *Optional* just inside both braces of an array initializer + - `new int[] {5, 6}` and `new int[] { 5, 6 }` are both valid + 10. Between a type annotation and `[]` or `...`. + + This rule is never interpreted as requiring or forbidding additional space at the start or end of a line; it addresses only *interior* space. + + ### **4.6.3 Horizontal alignment: never required** + + ![](https://google.github.io/styleguide/include/link.png) + + **Terminology Note:** *Horizontal alignment* is the practice of adding a variable number of additional spaces in your code with the goal of making certain tokens appear directly below certain other tokens on previous lines. + + This practice is permitted, but is **never required** by Google Style. It is not even required to *maintain* horizontal alignment in places where it was already used. + + Here is an example without alignment, then using alignment: + + ``` + private int x; // this is fine + private Color color; // this too + + private int x; // permitted, but future edits + private Color color; // may leave it unaligned + ``` + + **Tip:** Alignment can aid readability, but attempts to preserve alignment for its own sake create future problems. For example, consider a change that touches only one line. If that change disrupts the previous alignment, it's important **not** to introduce additional changes on nearby lines simply to realign them. Introducing formatting changes on otherwise unaffected lines corrupts version history, slows down reviewers, and exacerbates merge conflicts. These practical concerns take priority over alignment. + + ### **4.7 Grouping parentheses: recommended** + + ![](https://google.github.io/styleguide/include/link.png) + + Optional grouping parentheses are omitted only when author and reviewer agree that there is no reasonable chance the code will be misinterpreted without them, nor would they have made the code easier to read. It is *not* reasonable to assume that every reader has the entire Java operator precedence table memorized. + + ### **4.8 Specific constructs** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **4.8.1 Enum classes** + + ![](https://google.github.io/styleguide/include/link.png) + + After each comma that follows an enum constant, a line break is optional. Additional blank lines (usually just one) are also allowed. This is one possibility: + + ``` + private enum Answer { + YES { + @Override public String toString() { + return "yes"; + } + }, + + NO, + MAYBE + } + ``` + + An enum class with no methods and no documentation on its constants may optionally be formatted as if it were an array initializer (see Section 4.8.3.1 on [array initializers](https://google.github.io/styleguide/javaguide.html#s4.8.3.1-array-initializers)). + + ``` + private enum Suit { CLUBS, HEARTS, SPADES, DIAMONDS } + ``` + + Since enum classes *are classes*, all other rules for formatting classes apply. + + ### **4.8.2 Variable declarations** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **4.8.2.1 One variable per declaration** + + Every variable declaration (field or local) declares only one variable: declarations such as `int a, b;` are not used. + + **Exception:** Multiple variable declarations are acceptable in the header of a `for` loop. + + ### **4.8.2.2 Declared when needed** + + Local variables are **not** habitually declared at the start of their containing block or block-like construct. Instead, local variables are declared close to the point they are first used (within reason), to minimize their scope. Local variable declarations typically have initializers, or are initialized immediately after declaration. + + ### **4.8.3 Arrays** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **4.8.3.1 Array initializers: can be "block-like"** + + Any array initializer may *optionally* be formatted as if it were a "block-like construct." For example, the following are all valid (**not** an exhaustive list): + + ``` + new int[] { new int[] { + 0, 1, 2, 3 0, + } 1, + 2, + new int[] { 3, + 0, 1, } + 2, 3 + } new int[] + {0, 1, 2, 3} + ``` + + ### **4.8.3.2 No C-style array declarations** + + The square brackets form a part of the *type*, not the variable: `String[] args`, not `String args[]`. + + ### **4.8.4 Switch statements and expressions** + + ![](https://google.github.io/styleguide/include/link.png) + + For historical reasons, the Java language has two distinct syntaxes for `switch`, which we can call *old-style* and *new-style*. New-style switches use an arrow (`->`) after the switch labels, while old-style switches use a colon (`:`). + + **Terminology Note:** Inside the braces of a *switch block* are either one or more *switch rules* (new-style); or one or more *statement groups* (old-style). A *switch rule* consists of a *switch label* (`case ...` or `default`) followed by `->` and an expression, block, or `throw`. A statement group consists of one or more switch labels each followed by a colon, then one or more statements, or, for the *last* statement group, *zero* or more statements. (These definitions match the Java Language Specification, [§14.11](https://docs.oracle.com/javase/specs/jls/se21/html/jls-14.html#jls-14.11).) + + ### **4.8.4.1 Indentation** + + As with any other block, the contents of a switch block are indented +2. Each switch label starts with this +2 indentation. + + In a new-style switch, a switch rule can be written on a single line if it otherwise follows Google style. (It must not exceed the column limit, and if it contains a non-empty block then there must be a line break after `{`.) The line-wrapping rules of [Section 4.5](https://google.github.io/styleguide/javaguide.html#s4.5-line-wrapping) apply, including the +4 indent for continuation lines. For a switch rule with a non-empty block after the arrow, the same rules apply as for blocks elsewhere: lines between `{` and `}` are indented a further +2 relative to the line with the switch label. + + ``` + switch (number) { + case 0, 1 -> handleZeroOrOne(); + case 2 -> + handleTwoWithAnExtremelyLongMethodCallThatWouldNotFitOnTheSameLine(); + default -> { + logger.atInfo().log("Surprising number %s", number); + handleSurprisingNumber(number); + } + } + ``` + + In an old-style switch, the colon of each switch label is followed by a line break. The statements within a statement group start with a further +2 indentation. + + ### **4.8.4.2 Fall-through: commented** + + Within an old-style switch block, each statement group either terminates abruptly (with a `break`, `continue`, `return` or thrown exception), or is marked with a comment to indicate that execution will or *might* continue into the next statement group. Any comment that communicates the idea of fall-through is sufficient (typically `// fall through`). This special comment is not required in the last statement group of the switch block. Example: + + ``` + switch (input) { + case 1: + case 2: + prepareOneOrTwo(); + // fall through + case 3: + handleOneTwoOrThree(); + break; + default: + handleLargeNumber(input); + } + ``` + + Notice that no comment is needed after `case 1:`, only at the end of the statement group. + + There is no fall-through in new-style switches. + + ### **4.8.4.3 Exhaustiveness and presence of the `default` label** + + The Java language requires switch expressions and many kinds of switch statements to be *exhaustive*. That effectively means that every possible value that could be switched on will be matched by one of the switch labels. A switch is exhaustive if it has a `default` label, but also for example if the value being switched on is an enum and every value of the enum is matched by a switch label. Google Style requires *every* switch to be exhaustive, even those where the language itself does not require it. This may require adding a `default` label, even if it contains no code. + + ### **4.8.4.4 Switch expressions** + + Switch expressions must be new-style switches: + + ``` + return switch (list.size()) { + case 0 -> ""; + case 1 -> list.getFirst(); + default -> String.join(", ", list); + }; + ``` + + ### **4.8.5 Annotations** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **4.8.5.1 Type-use annotations** + + Type-use annotations appear immediately before the annotated type. An annotation is a type-use annotation if it is meta-annotated with `@Target(ElementType.TYPE_USE)`. Example: + + ``` + final @Nullable String name; + + public @Nullable Person getPersonByName(String name); + ``` + + ### **4.8.5.2 Class, package, and module annotations** + + Annotations applying to a class, package, or module declaration appear immediately after the documentation block, and each annotation is listed on a line of its own (that is, one annotation per line). These line breaks do not constitute line-wrapping (Section 4.5, [Line-wrapping](https://google.github.io/styleguide/javaguide.html#s4.5-line-wrapping)), so the indentation level is not increased. Examples: + + ``` + /** This is a class. */ + @Deprecated + @CheckReturnValue + public final class Frozzler { ... } + ``` + + ``` + /** This is a package. */ + @Deprecated + @CheckReturnValue + package com.example.frozzler; + ``` + + ``` + /** This is a module. */ + @Deprecated + @SuppressWarnings("CheckReturnValue") + module com.example.frozzler { ... } + ``` + + ### **4.8.5.3 Method and constructor annotations** + + The rules for annotations on method and constructor declarations are the same as the [previous section](https://google.github.io/styleguide/javaguide.html#s4.8.5.2-class-annotation-style). Example: + + ``` + @Deprecated + @Override + public String getNameIfPresent() { ... } + ``` + + **Exception:** A *single* parameterless annotation *may* instead appear together with the first line of the signature, for example: + + ``` + @Override public int hashCode() { ... } + ``` + + ### **4.8.5.4 Field annotations** + + Annotations applying to a field also appear immediately after the documentation block, but in this case, *multiple* annotations (possibly parameterized) may be listed on the same line; for example: + + ``` + @Partial @Mock DataLoader loader; + ``` + + ### **4.8.5.5 Parameter and local variable annotations** + + There are no specific rules for formatting annotations on parameters or local variables (except, of course, when the annotation is a type-use annotation). + + ### **4.8.6 Comments** + + ![](https://google.github.io/styleguide/include/link.png) + + This section addresses *implementation comments*. Javadoc is addressed separately in Section 7, [Javadoc](https://google.github.io/styleguide/javaguide.html#s7-javadoc). + + Any line break may be preceded by arbitrary whitespace followed by an implementation comment. Such a comment renders the line non-blank. + + ### **4.8.6.1 Block comment style** + + Block comments are indented at the same level as the surrounding code. They may be in `/* ... */` style or `// ...` style. For multi-line `/* ... */` comments, subsequent lines must start with `*` aligned with the `*` on the previous line. + + ``` + /* + * This is // And so /* Or you can + * okay. // is this. * even do this. */ + */ + ``` + + Comments are not enclosed in boxes drawn with asterisks or other characters. + + **Tip:** When writing multi-line comments, use the `/* ... */` style if you want automatic code formatters to re-wrap the lines when necessary (paragraph-style). Most formatters don't re-wrap lines in `// ...` style comment blocks. + + ### **4.8.6.2 TODO comments** + + Use `TODO` comments for code that is temporary, a short-term solution, or good-enough but not perfect. + + A `TODO` comment begins with the word `TODO` in all caps, a following colon, and a link to a resource that contains the context, ideally a bug reference. A bug reference is preferable because bugs are tracked and have follow-up comments. Follow this piece of context with an explanatory string introduced with a hyphen `-`. + + The purpose is to have a consistent `TODO` format that can be searched to find out how to get more details. + + ``` + // TODO: crbug.com/12345678 - Remove this after the 2047q4 compatibility window expires. + + ``` + + Avoid adding TODOs that refer to an individual or team as the context: + + ``` + // TODO: @yourusername - File an issue and use a '*' for repetition. + + ``` + + If your `TODO` is of the form "At a future date do something" make sure that you either include a very specific date ("Fix by November 2005") or a very specific event ("Remove this code when all clients can handle XML responses."). + + ### **4.8.7 Modifiers** + + ![](https://google.github.io/styleguide/include/link.png) + + Class and member modifiers, when present, appear in the order recommended by the Java Language Specification: + + ``` + public protected private abstract default static final sealed non-sealed + transient volatile synchronized native strictfp + + ``` + + Modifiers on `requires` module directives, when present, appear in the following order: + + ``` + transitive static + ``` + + ### **4.8.8 Numeric Literals** + + ![](https://google.github.io/styleguide/include/link.png) + + `long`-valued integer literals use an uppercase `L` suffix, never lowercase (to avoid confusion with the digit `1`). For example, `3000000000L` rather than `3000000000l`. + + ### **4.8.9 Text Blocks** + + ![](https://google.github.io/styleguide/include/link.png) + + The opening `"""` of a text block is always on a new line. That line may either follow the same indentation rules as other constructs, or it may have no indentation at all (so it starts at the left margin). The closing `"""` is on a new line with the same indentation as the opening `"""`, and may be followed on the same line by further code. Each line of text in the text block is indented at least as much as the opening and closing `"""`. (If a line is indented further, then the string literal defined by the text block will have space at the start of that line.) + + The contents of a text block may exceed the [column limit](https://google.github.io/styleguide/javaguide.html#columnlimit). + + ## **5 Naming** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **5.1 Rules common to all identifiers** + + ![](https://google.github.io/styleguide/include/link.png) + + Identifiers use only ASCII letters and digits, and, in a small number of cases noted below, underscores. Thus each valid identifier name is matched by the regular expression `\w+` . + + In Google Style, special prefixes or suffixes are **not** used. For example, these names are not Google Style: `name_`, `mName`, `s_name` and `kName`. + + ### **5.2 Rules by identifier type** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **5.2.1 Package and module names** + + ![](https://google.github.io/styleguide/include/link.png) + + Package and module names use only lowercase letters and digits (no underscores). Consecutive words are simply concatenated together. For example, `com.example.deepspace`, not `com.example.deepSpace` or `com.example.deep_space`. + + ### **5.2.2 Class names** + + ![](https://google.github.io/styleguide/include/link.png) + + Class names are written in [UpperCamelCase](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case). + + Class names are typically nouns or noun phrases. For example, `Character` or `ImmutableList`. Interface names may also be nouns or noun phrases (for example, `List`), but may sometimes be adjectives or adjective phrases instead (for example, `Readable`). + + There are no specific rules or even well-established conventions for naming annotation types. + + A *test* class has a name that ends with `Test`, for example, `HashIntegrationTest`. If it covers a single class, its name is the name of that class plus `Test`, for example `HashImplTest`. + + ### **5.2.3 Method names** + + ![](https://google.github.io/styleguide/include/link.png) + + Method names are written in [lowerCamelCase](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case). + + Method names are typically verbs or verb phrases. For example, `sendMessage` or `stop`. + + Underscores may appear in JUnit *test* method names to separate logical components of the name, with *each* component written in [lowerCamelCase](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case), for example `transferMoney_deductsFromSource`. There is no One Correct Way to name test methods. + + ### **5.2.4 Constant names** + + ![](https://google.github.io/styleguide/include/link.png) + + Constant names use `UPPER_SNAKE_CASE`: all uppercase letters, with each word separated from the next by a single underscore. But what *is* a constant, exactly? + + Constants are static final fields whose contents are deeply immutable and whose methods have no detectable side effects. Examples include primitives, strings, immutable value classes, and anything set to `null`. If any of the instance's observable state can change, it is not a constant. Merely *intending* to never mutate the object is not enough. Examples: + + ``` + // Constants + static final int NUMBER = 5; + static final ImmutableList NAMES = ImmutableList.of("Ed", "Ann"); + static final Map AGES = ImmutableMap.of("Ed", 35, "Ann", 32); + static final Joiner COMMA_JOINER = Joiner.on(','); // because Joiner is immutable + static final SomeMutableType[] EMPTY_ARRAY = {}; + + // Not constants + static String nonFinal = "non-final"; + final String nonStatic = "non-static"; + static final Set mutableCollection = new HashSet(); + static final ImmutableSet mutableElements = ImmutableSet.of(mutable); + static final ImmutableMap mutableValues = + ImmutableMap.of("Ed", mutableInstance, "Ann", mutableInstance2); + static final Logger logger = Logger.getLogger(MyClass.getName()); + static final String[] nonEmptyArray = {"these", "can", "change"}; + ``` + + These names are typically nouns or noun phrases. + + ### **5.2.5 Non-constant field names** + + ![](https://google.github.io/styleguide/include/link.png) + + Non-constant field names (static or otherwise) are written in [lowerCamelCase](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case). + + These names are typically nouns or noun phrases. For example, `computedValues` or `index`. + + ### **5.2.6 Parameter names** + + ![](https://google.github.io/styleguide/include/link.png) + + Parameter names are written in [lowerCamelCase](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case). + + One-character parameter names in public methods should be avoided. + + ### **5.2.7 Local variable names** + + ![](https://google.github.io/styleguide/include/link.png) + + Local variable names are written in [lowerCamelCase](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case). + + Even when final and immutable, local variables are not considered to be constants, and should not be styled as constants. + + ### **5.2.8 Type variable names** + + ![](https://google.github.io/styleguide/include/link.png) + + Each type variable is named in one of two styles: + + - A single capital letter, optionally followed by a single numeral (such as `E`, `T`, `X`, `T2`) + - A name in the form used for classes (see Section 5.2.2, [Class names](https://google.github.io/styleguide/javaguide.html#s5.2.2-class-names)), followed by the capital letter `T` (examples: `RequestT`, `FooBarT`). + + ### **5.3 Camel case: defined** + + ![](https://google.github.io/styleguide/include/link.png) + + Sometimes there is more than one reasonable way to convert an English phrase into camel case, such as when acronyms or unusual constructs like "IPv6" or "iOS" are present. To improve predictability, Google Style specifies the following (nearly) deterministic scheme. + + Beginning with the prose form of the name: + + 1. Convert the phrase to plain ASCII and remove any apostrophes. For example, "Müller's algorithm" might become "Muellers algorithm". + 2. Divide this result into words, splitting on spaces and any remaining punctuation (typically hyphens). + - *Recommended:* if any word already has a conventional camel-case appearance in common usage, split this into its constituent parts (e.g., "AdWords" becomes "ad words"). Note that a word such as "iOS" is not really in camel case *per se*; it defies *any* convention, so this recommendation does not apply. + 3. Now lowercase *everything* (including acronyms), then uppercase only the first character of: + - ... each word, to yield *upper camel case*, or + - ... each word except the first, to yield *lower camel case* + 4. Finally, join all the words into a single identifier. Note that the casing of the original words is almost entirely disregarded. + + In very rare circumstances (for example, multipart version numbers), you may need to use underscores to separate adjacent numbers, since numbers do not have upper and lower case variants. + + Examples: + + | **Prose form** | **Correct** | **Incorrect** | + | --- | --- | --- | + | "XML HTTP request" | `XmlHttpRequest` | `XMLHTTPRequest` | + | "new customer ID" | `newCustomerId` | `newCustomerID` | + | "inner stopwatch" | `innerStopwatch` | `innerStopWatch` | + | "supports IPv6 on iOS?" | `supportsIpv6OnIos` | `supportsIPv6OnIOS` | + | "YouTube importer" | `YouTubeImporterYoutubeImporter`* | | + | "Turn on 2SV" | `turnOn2sv` | `turnOn2Sv` | + | "Guava 33.4.6" | `guava33_4_6` | `guava3346` | + - Acceptable, but not recommended. + + **Note:** Some words are ambiguously hyphenated in the English language: for example "nonempty" and "non-empty" are both correct, so the method names `checkNonempty` and `checkNonEmpty` are likewise both correct. + + ## **6 Programming Practices** + + ![](https://google.github.io/styleguide/include/link.png) + + ### **6.1 `@Override`: always used** + + ![](https://google.github.io/styleguide/include/link.png) + + A method is marked with the `@Override` annotation whenever it is legal. This includes a class method overriding a superclass method, a class method implementing an interface method, an interface method respecifying a superinterface method, and an explicitly declared accessor method for a record component. + + **Exception:** `@Override` may be omitted when the parent method is `@Deprecated`. + + ### **6.2 Caught exceptions: not ignored** + + ![](https://google.github.io/styleguide/include/link.png) + + It is very rarely correct to do nothing in response to a caught exception. (Typical responses are to log it, or if it is considered "impossible", rethrow it as an `AssertionError`.) + + When it truly is appropriate to take no action whatsoever in a catch block, the reason this is justified is explained in a comment. + + ``` + try { + int i = Integer.parseInt(response); + return handleNumericResponse(i); + } catch (NumberFormatException ok) { + // it's not numeric; that's fine, just continue + } + return handleTextResponse(response); + ``` + + ### **6.3 Static members: qualified using class** + + ![](https://google.github.io/styleguide/include/link.png) + + When a reference to a static class member must be qualified, it is qualified with that class's name, not with a reference or expression of that class's type. + + ``` + Foo aFoo = ...; + Foo.aStaticMethod(); // good + aFoo.aStaticMethod(); // bad + somethingThatYieldsAFoo().aStaticMethod(); // very bad + ``` + + ### **6.4 Finalizers: not used** + + ![](https://google.github.io/styleguide/include/link.png) + + Do not override `Object.finalize`. Finalization support is [*scheduled for removal*](https://openjdk.org/jeps/421). + +# 개행 길이 + +- 최대 길이 : 160 + +## 메소드 파라미터 규칙 + +- 레코드, 컨트롤러 : 하나씩 개행 +- 어노테이션이 붙은 메소드나 길이 160을 초과하는 메소드 : 하나씩 개행 +- 일반 메소드 : 개행 안함 + +# 네이밍 규칙 + +--- + +## 메소드 이름 + +- 메소드 이름은 **행위 + 도메인** 형태로 작성한다. + + ```java + public void saveMember() {} + ``` + +- `getter`가 아닌 메서드에서`get-`을 사용하지 않는다. +- 검증 메소드에 `not` 을 사용하지 않는다. +- 메소드 파라미터에는`final`을 사용하지 않는다. +- 반환 값의 존재 여부에 따라 예외 발생 여부를 결정하는 메소드명 규칙 + - read : 반드시 존재해야 함. 존재하지 않으면 예외 발생. 가령 List 를 반환할 때 비어있으면 안됨 + - find : 존재하지 않아도 됨. 빈 리스트 반환하거나 Optional 반환 가능 + +# 패키지 구조 + +--- + +- 도메인 별로 패키지를 먼저 나눈 후 Layered Architecture로 나눈다. +- 만약 컨트롤러에서만 사용하는 dto 가 있다면, dto 패키지는 컨트롤러 패키지 하위로 간다. +- 최상위: 도메인 별 패키지와 `global` 패키지 + + 하위: Layered Architecture + + ```java + domain + └── controller + └── service + └── repositroy + └── domain + └── dto + global + ``` + +# 생성자 + +--- + +- 생성자는 인자의 할당과 검증 로직만 수행한다. + - 검증 로직을 어떻게 할것인가? +- 정팩메는 진짜 적합한 경우에만 + - 이름을 진짜 붙여주고 싶을 때 + - 캐싱 + - 싱글톤 등등 + - enum + +# 클래스 구조 + +--- + +- 클래스 맨 처음 줄은 띄고 시작한다. +- 도메인은 toString 을 모두 오버라이딩한다. + +### 메소드 선언 순서 + +``` +1. 생성자 +2. 정적 팩토리 메소드 +3. 메소드(접근자 기준이 아닌 기능 및 역할별로 분류하여 작성) + 3-1. private 메소드는 마지막으로 호출하는 public 메소드 밑에 + 3-2. CRUD 순서로 배치 +4. 오버라이딩 메소드 -> equals, hashcode, toString 등 +``` + +- 생성자, `getter`와 `setter`를 포함한 기본 메소드는 `Lombok`으로 대체한다. + +# 어노테이션 선언 순서 + +--- + +- Spring Annotation → Lombok Annotation 순으로 작성 +- 중요한 어노테이션일수록 아래로 + + ```java + @Lombok + @SpringAnnotation + public void example(){} + ``` + +# DTO + +--- + +## 이름 규칙 + +- DTO 이름은 `Request`와 `Response`로 마친다. + +## 침투 계층 + +- 서비스에서는 DTO를 파라미터로 받고, DTO를 반환한다. +- 컨트롤러에서는 도메인을 모르도록 한다. + +## 검증 + +- DTO에서 @Valid 로 검증 가능한 것들은 DTO에서 하고, 불가능한 경우 엔티티에서 직접 검증한다. +- 엔티티 레벨에서 검증이 어렵다면 서비스에서 한다. (가령 멤버 엔티티의 이름이 중복되는지 검증하는 경우 서비스에서 검증) + +# 기타 + +- Enum의 값을 정의할 때, 후행 쉼표를 사용한다. + +# 테스트 + +--- + +## 메소드 이름 + +- 테스트 메소드 이름은 **한국어**로 작성한다. + +## @DisplayName + +- 쓰지 않는다. + +## 테스트 작성 방식 + +- 피라미드 구조의 테스트를 지향 +- 통합테스트를 기저로 실제 객체를 쓰되 정말 필요한 경우 (Ex. RestClient) mock 사용 + +### 컨트롤러 + +- MockMvc + 실제 Bean을 활용한 통합 테스트 +- 컨트롤러 테스트는 로직을 검증하는 것이 아니라 요청/응답이 잘 오는지 검증하는 것이 목적 +- 특히 테스트 메소드 하나가 RestDocs 문서 하나에 대응된다. + +### 서비스 + +- 레포지토리와 통합 테스트 +- MockBean을 추상 클래스에서 모두 주입받아 `@SpringBootTest` 에서 사용하는 애플리케이션컨텍스트를 통일한다. + +### 레포지토리 + +- 작성은 필수가 아님 +- 직접 작성한 쿼리를 검증할 경우가 생길 경우 도입 + +### 도메인 + +- 단위 테스트 + +### fixture + +- global/fixture 패키지의 XxxFixtureBuilder 를 사용해 엔티티 객체를 만들고, 리포지토리로 저장한다. + +### 테스트 데이터 초기화 + +- `data.sql`을 쓰지 않고 각 테스트 메서드의 given 절에서 테스트 데이터를 초기화한다. + - 하나의 테스트 메서드를 이해하기 위해 다른 파일을 열어봐야 하는 일이 없어야 하기 때문에 + - 테스트 메서드마다 완전한 격리성을 갖기 위해 + +### 테스트 격리 + +- `@Transcational` vs `DatabaseCleaner` + - JPA를 사용하는 경우 쓰기지연저장소에 남아있는 쿼리들이 아예 실행조차 되지 않아 문제가 발생할 수 있음 + - Service에서 `@Transcational` 자체를 빼먹는 실수를 못 잡고, 복잡한 테스트에서 여러 개의 Service를 거쳐야 하는 경우 트랜잭션이 의도치 않게 묶일 수 있음 +- 따라서 `DatabaseCleaner` 를 쓴다. + +### 테스트 컨테이너 + +- DB를 필요로 하는 모든 테스트에서 H2가 아닌 mysql 테스트 컨테이너를 사용한다. + +# URI + +--- + +- URI 구조는 **RESTful API를 따르는 것을 기본 원칙**으로 한다. + +# Exception + +--- + +## Custom Exception 사용 + +```java +BadRequestException + UnauthorizedException +ForbbidenException +``` + +- 로깅 때 분리해서 보고 싶은 경우 클래스 분리 +- 혼란을 방지하기 위해 최소한의 Custom Exception 사용 +- 커스텀 Exception클래스는 message, responseCode만을 속성으로 가진다. + +# 검증 + +--- + +- domain 객체단에서는 `@Lombok` 사용 없이 직접 생성자 생성 및 검증. +- 검증 로직은 주생성자에 넣어둔 후, 주생성자를 이용한 생성만 허용한다. +- 검증 후 예외 발생은 도메인의 책임이다. +- 불가피한 경우 서비스에게 위임한다. + +# 로깅 컨벤션 + +--- + +- logback 사용 +- MDC를 사용중이므로 다른 스레드에서 로깅시 MDC 컨텍스트를 전달해야 한다. diff --git a/domain/src/main/java/com/samhap/kokomen/global/domain/BaseEntity.java b/domain/src/main/java/com/samhap/kokomen/global/domain/BaseEntity.java index 0bb2cae..45ba2a7 100644 --- a/domain/src/main/java/com/samhap/kokomen/global/domain/BaseEntity.java +++ b/domain/src/main/java/com/samhap/kokomen/global/domain/BaseEntity.java @@ -6,6 +6,7 @@ import java.time.LocalDateTime; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Getter @@ -16,4 +17,8 @@ public abstract class BaseEntity { @CreatedDate @Column(updatable = false, nullable = false) private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; } diff --git a/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java new file mode 100644 index 0000000..65662fd --- /dev/null +++ b/domain/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/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java new file mode 100644 index 0000000..a1f9cad --- /dev/null +++ b/domain/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 +} \ No newline at end of file diff --git a/domain/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java new file mode 100644 index 0000000..94030eb --- /dev/null +++ b/domain/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/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java new file mode 100644 index 0000000..dd8a206 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -0,0 +1,84 @@ +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.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@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; + + 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, String metadata) { + if (!this.paymentKey.equals(paymentKey)) { + throw new IllegalStateException("토스 페이먼츠 응답(%s)의 paymentKey가 DB에 저장된 값(%s)과 다릅니다.".formatted(paymentKey, this.paymentKey)); + } + if (!this.orderId.equals(orderId)) { + throw new IllegalStateException("토스 페이먼츠 응답(%s)의 orderId가 DB에 저장된 값(%s)과 다릅니다.".formatted(orderId, this.orderId)); + } + if (!this.totalAmount.equals(totalAmount)) { + throw new IllegalStateException("토스 페이먼츠 응답(%d)의 totalAmount가 DB에 저장된 값(%d)과 다릅니다.".formatted(totalAmount, this.totalAmount)); + } +// 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/domain/TosspaymentsPaymentResult.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java new file mode 100644 index 0000000..92d7b0b --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java @@ -0,0 +1,148 @@ +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; + +@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; + + 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/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java new file mode 100644 index 0000000..b598214 --- /dev/null +++ b/domain/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/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java b/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java new file mode 100644 index 0000000..fc38fe1 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java @@ -0,0 +1,9 @@ +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); +} \ No newline at end of file diff --git a/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java b/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java new file mode 100644 index 0000000..83110d4 --- /dev/null +++ b/domain/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/domain/src/main/resources/db/migration/V1__create_tosspayments_tables.sql b/domain/src/main/resources/db/migration/V1__create_tosspayments_tables.sql new file mode 100644 index 0000000..eaa1caf --- /dev/null +++ b/domain/src/main/resources/db/migration/V1__create_tosspayments_tables.sql @@ -0,0 +1,46 @@ +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, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY idx_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), + 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) +); diff --git a/domain/src/main/resources/db/migration/V2__add_service_type_and_failure_message_column.sql b/domain/src/main/resources/db/migration/V2__add_service_type_and_failure_message_column.sql new file mode 100644 index 0000000..cd2fe6b --- /dev/null +++ b/domain/src/main/resources/db/migration/V2__add_service_type_and_failure_message_column.sql @@ -0,0 +1,8 @@ +ALTER TABLE tosspayments_payment + ADD COLUMN service_type ENUM('INTERVIEW') NOT NULL DEFAULT 'INTERVIEW' AFTER state; + +ALTER TABLE tosspayments_payment + ALTER COLUMN service_type DROP DEFAULT; + +ALTER TABLE tosspayments_payment_result + ADD COLUMN failure_message TEXT AFTER failure_code; diff --git a/domain/src/main/resources/db/migration/V3__add_cancel_columns.sql b/domain/src/main/resources/db/migration/V3__add_cancel_columns.sql new file mode 100644 index 0000000..673ad47 --- /dev/null +++ b/domain/src/main/resources/db/migration/V3__add_cancel_columns.sql @@ -0,0 +1,4 @@ +ALTER TABLE tosspayments_payment_result +ADD COLUMN cancel_reason VARCHAR(200) DEFAULT NULL, +ADD COLUMN canceled_at DATETIME DEFAULT NULL, +ADD COLUMN cancel_status VARCHAR(50) DEFAULT NULL; \ No newline at end of file diff --git a/external/build.gradle b/external/build.gradle new file mode 100644 index 0000000..a818bbd --- /dev/null +++ b/external/build.gradle @@ -0,0 +1,17 @@ +dependencies { + implementation project(':domain') + implementation project(':common') +// implementation 'org.springframework:spring-web' +// implementation 'org.springframework:spring-core' +// implementation 'org.springframework:spring-beans' +// implementation 'org.springframework:spring-context' + implementation 'org.springframework.boot:spring-boot-starter-web' +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} diff --git a/external/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java b/external/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java new file mode 100644 index 0000000..69d1d9f --- /dev/null +++ b/external/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.global.dto; + +public record ErrorResponse( + String 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 new file mode 100644 index 0000000..4f880ec --- /dev/null +++ b/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,78 @@ +package com.samhap.kokomen.global.exception; + +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.samhap.kokomen.global.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +// TODO: HttpMessageNotReadableException 예외 처리 추가 +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(KokomenException.class) + public ResponseEntity handleKokomenException(KokomenException e) { + log.warn("KokomenException :: status: {}, message: {}", e.getHttpStatusCode(), e.getMessage()); + return ResponseEntity.status(e.getHttpStatusCode()) + .body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String defaultErrorMessageForUser = "잘못된 요청입니다."; + String message = e.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse(defaultErrorMessageForUser); + + if (message.equals(defaultErrorMessageForUser)) { + log.warn("MethodArgumentNotValidException :: message: {}", e.getMessage()); + } else { + log.warn("MethodArgumentNotValidException :: message: {}", message); + } + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(message)); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + String message = "필수 요청 파라미터 '" + e.getParameterName() + "'가 누락되었습니다."; + log.warn("MissingServletRequestParameterException :: message: {}", message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(message)); + } + + @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 :: message: {}", message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(message)); + } + + @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("서버에 문제가 발생하였습니다.")); + } +} diff --git a/external/src/main/java/com/samhap/kokomen/global/exception/HttpBadRequestErrorException.java b/external/src/main/java/com/samhap/kokomen/global/exception/HttpBadRequestErrorException.java new file mode 100644 index 0000000..7714a9a --- /dev/null +++ b/external/src/main/java/com/samhap/kokomen/global/exception/HttpBadRequestErrorException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class HttpBadRequestErrorException extends BadRequestException { + + public HttpBadRequestErrorException(String message) { + super(message); + } +} diff --git a/external/src/main/java/com/samhap/kokomen/global/exception/HttpInternalServerErrorException.java b/external/src/main/java/com/samhap/kokomen/global/exception/HttpInternalServerErrorException.java new file mode 100644 index 0000000..d1e0c26 --- /dev/null +++ b/external/src/main/java/com/samhap/kokomen/global/exception/HttpInternalServerErrorException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class HttpInternalServerErrorException extends RuntimeException { + + public HttpInternalServerErrorException(String message) { + super(message); + } +} diff --git a/external/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java b/external/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java new file mode 100644 index 0000000..38fa878 --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java b/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java new file mode 100644 index 0000000..d1ef77c --- /dev/null +++ b/external/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(converter -> converter instanceof MappingJackson2HttpMessageConverter); + 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/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java b/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java new file mode 100644 index 0000000..cebe58b --- /dev/null +++ b/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java @@ -0,0 +1,65 @@ +package com.samhap.kokomen.payment.external; + +import com.samhap.kokomen.global.annotation.ExecutionTimer; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +@Slf4j +@ExecutionTimer +@Component +public class TosspaymentsClient { + + private final RestClient restClient; + + public TosspaymentsClient(TossPaymentsClientBuilder tossPaymentsClientBuilder) { + this.restClient = tossPaymentsClientBuilder.getTossPaymentsClientBuilder().build(); + } + + public TosspaymentsPaymentResponse confirmPayment(TosspaymentsConfirmRequest request) { + try { + // TODO: interceptor로 로깅 처리 + log.info("토스페이먼츠 결제 승인 API 호출 - paymentKey: {}, orderId: {}, amount: {}", + request.paymentKey(), request.orderId(), request.amount()); + + TosspaymentsPaymentResponse response = restClient.post() + .uri("/v1/payments/confirm") + .body(request) + .retrieve() + .body(TosspaymentsPaymentResponse.class); + log.info("토스페이먼츠 결제 승인 완료 - response: {}", response); + + return response; + + } catch (HttpClientErrorException e) { + log.error("토스페이먼츠 결제 승인 실패 - paymentKey: {}, status: {}, responseBody: {}", + request.paymentKey(), e.getStatusCode(), e.getResponseBodyAsString(), e); + throw e; + } + } + + public TosspaymentsPaymentResponse cancelPayment(String paymentKey, TosspaymentsPaymentCancelRequest request) { + try { + log.info("토스페이먼츠 결제 취소 API 호출 - paymentKey: {}, cancelReason: {}", + paymentKey, request.cancelReason()); + + TosspaymentsPaymentResponse response = restClient.post() + .uri("/v1/payments/{paymentKey}/cancel", paymentKey) + .body(request) + .retrieve() + .body(TosspaymentsPaymentResponse.class); + log.info("토스페이먼츠 결제 취소 완료 - response: {}", response); + + return response; + + } catch (HttpClientErrorException e) { + log.error("토스페이먼츠 결제 취소 실패 - paymentKey: {}, status: {}, responseBody: {}", + paymentKey, e.getStatusCode(), e.getResponseBodyAsString(), e); + throw e; + } + } +} diff --git a/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java b/external/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java new file mode 100644 index 0000000..01c39dc --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java new file mode 100644 index 0000000..e4374f2 --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java new file mode 100644 index 0000000..e441b2d --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java new file mode 100644 index 0000000..d20fc8a --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java new file mode 100644 index 0000000..9637829 --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java new file mode 100644 index 0000000..e2f6af8 --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java new file mode 100644 index 0000000..9ae115f --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java new file mode 100644 index 0000000..5740f87 --- /dev/null +++ b/external/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/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java new file mode 100644 index 0000000..34b2c10 --- /dev/null +++ b/external/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 +) { +} \ No newline at end of file diff --git a/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java b/external/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java new file mode 100644 index 0000000..2d0fd87 --- /dev/null +++ b/external/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/external/src/main/resources/application-external-test.yml b/external/src/main/resources/application-external-test.yml new file mode 100644 index 0000000..9a91dfe --- /dev/null +++ b/external/src/main/resources/application-external-test.yml @@ -0,0 +1,12 @@ +#spring: +# datasource: +# url: jdbc:mysql://localhost:13308/kokomen-payment-test?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 +# driver-class-name: com.mysql.cj.jdbc.Driver +# username: root +# password: root +# jpa: +# hibernate: +# ddl-auto: none +# show-sql: true +# database-platform: org.hibernate.dialect.MySQL8Dialect +# diff --git a/external/src/main/resources/application-external.yml b/external/src/main/resources/application-external.yml new file mode 100644 index 0000000..40b4e4d --- /dev/null +++ b/external/src/main/resources/application-external.yml @@ -0,0 +1,23 @@ +# common configuration for all profiles +--- +# local profile +spring: + config: + activate: + on-profile: local +tosspayments: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 +--- +# dev profile +spring: + config: + activate: + on-profile: dev +tosspayments: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 +--- +# prod profile +spring: + config: + activate: + on-profile: prod diff --git a/internal/build.gradle b/internal/build.gradle index 376861f..36f176a 100644 --- a/internal/build.gradle +++ b/internal/build.gradle @@ -9,13 +9,15 @@ ext { dependencies { implementation project(':domain') implementation project(':common') + implementation project(':external') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-validation' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' } tasks.named('test') { diff --git a/internal/src/main/java/com/samhap/kokomen/KokomenPaymentInternalApplication.java b/internal/src/main/java/com/samhap/kokomen/KokomenPaymentInternalApplication.java index d0fd60a..5073825 100644 --- a/internal/src/main/java/com/samhap/kokomen/KokomenPaymentInternalApplication.java +++ b/internal/src/main/java/com/samhap/kokomen/KokomenPaymentInternalApplication.java @@ -11,5 +11,4 @@ public class KokomenPaymentInternalApplication { public static void main(String[] args) { SpringApplication.run(KokomenPaymentInternalApplication.class, args); } - } diff --git a/internal/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java b/internal/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java new file mode 100644 index 0000000..c29bd4f --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.global.dto; + +public record ErrorResponse( + String message +) { +} \ No newline at end of file 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 new file mode 100644 index 0000000..01fb80d --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.samhap.kokomen.global.exception; + +import com.samhap.kokomen.global.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(KokomenException.class) + public ResponseEntity handleKokomenException(KokomenException e) { + log.warn("KokomenException :: status: {}, message: {}", e.getHttpStatusCode(), e.getMessage()); + return ResponseEntity.status(e.getHttpStatusCode()) + .body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String defaultErrorMessageForUser = "잘못된 요청입니다."; + String message = e.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse(defaultErrorMessageForUser); + + if (message.equals(defaultErrorMessageForUser)) { + log.warn("MethodArgumentNotValidException :: message: {}", e.getMessage()); + } else { + log.warn("MethodArgumentNotValidException :: message: {}", message); + } + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(message)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.warn("HttpMessageNotReadableException :: message: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("잘못된 요청 형식입니다.")); + } + + @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("서버에 문제가 발생하였습니다.")); + } +} \ No newline at end of file diff --git a/internal/src/main/java/com/samhap/kokomen/payment/controller/PaymentController.java b/internal/src/main/java/com/samhap/kokomen/payment/controller/PaymentController.java new file mode 100644 index 0000000..48587fd --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/payment/controller/PaymentController.java @@ -0,0 +1,33 @@ +package com.samhap.kokomen.payment.controller; + +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/internal/v1/payments") +@RestController +public class PaymentController { + + private final PaymentFacadeService paymentFacadeService; + + @PostMapping("/confirm") + public ResponseEntity confirmPayment(@RequestBody @Valid ConfirmRequest request) { + PaymentResponse response = paymentFacadeService.confirmPayment(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/cancel") + public ResponseEntity cancelPayment(@RequestBody @Valid CancelRequest request) { + paymentFacadeService.cancelPayment(request); + return ResponseEntity.noContent().build(); + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/payment/controller/PaymentTestController.java b/internal/src/main/java/com/samhap/kokomen/payment/controller/PaymentTestController.java new file mode 100644 index 0000000..fe0b75b --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/payment/controller/PaymentTestController.java @@ -0,0 +1,32 @@ +package com.samhap.kokomen.payment.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Profile("local") +@RequiredArgsConstructor +@Controller +public class PaymentTestController { + + @GetMapping("/payment-test") + public String paymentTestPage() { + return "payment-test"; + } + + @GetMapping("/payment/success") + public String paymentSuccessPage() { + return "payment-success"; + } + + @GetMapping("/payment/fail") + public String paymentFailPage() { + return "payment-fail"; + } + + @GetMapping("/payment/refund") + public String paymentRefundPage() { + return "payment-refund"; + } +} 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 new file mode 100644 index 0000000..0c79032 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -0,0 +1,89 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.global.exception.BadRequestException; +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.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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +@RequiredArgsConstructor +@Service +public class PaymentFacadeService { + + private final TosspaymentsTransactionService tosspaymentsTransactionService; + private final TosspaymentsPaymentService tosspaymentsPaymentService; + private final TosspaymentsClient tosspaymentsClient; + + public PaymentResponse confirmPayment(ConfirmRequest request) { + TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request); + try { + TosspaymentsPaymentResponse tosspaymentsPaymentResponse = confirmPayment(request, tosspaymentsPayment); + return PaymentResponse.from(tosspaymentsPaymentResponse); + } catch (Exception e) { + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + throw e; + } + } + + private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, TosspaymentsPayment tosspaymentsPayment) { + try { + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest()); + tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), tosspaymentsConfirmResponse.orderId(), + tosspaymentsConfirmResponse.totalAmount(), tosspaymentsConfirmResponse.metadata()); + TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); + tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.COMPLETED); + return tosspaymentsConfirmResponse; + } catch (HttpClientErrorException e) { + 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); + } catch (HttpServerErrorException e) { + // TODO: retry + 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; + } + } + + throw e; + } + } + + public void cancelPayment(CancelRequest request) { + TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(request.cancelReason()); + TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), tosspaymentsPaymentCancelRequest); + tosspaymentsTransactionService.applyCancelResult(response); + } +} 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 new file mode 100644 index 0000000..6e90502 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java @@ -0,0 +1,25 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@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(() -> new IllegalStateException("해당 결제의 결과 정보가 존재하지 않습니다. tosspaymentsPaymentId: " + tosspaymentsPaymentId)); + } +} 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 new file mode 100644 index 0000000..c4d7259 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java @@ -0,0 +1,39 @@ +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.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TosspaymentsPaymentService { + + private final TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Transactional + public TosspaymentsPayment saveTosspaymentsPayment(ConfirmRequest request) { + return tosspaymentsPaymentRepository.save(request.toTosspaymentsPayment()); + } + + @Transactional(readOnly = true) + public TosspaymentsPayment readById(Long id) { + return tosspaymentsPaymentRepository.findById(id) + .orElseThrow(() -> new IllegalStateException("해당 id의 결제 정보가 존재하지 않습니다. id: " + id)); + } + + @Transactional(readOnly = true) + public TosspaymentsPayment readByPaymentKey(String paymentKey) { + return tosspaymentsPaymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> new IllegalStateException("해당 paymentKey의 결제 정보가 존재하지 않습니다. paymentKey: " + paymentKey)); + } + + @Transactional + public void updateState(Long tosspaymentsPaymentId, PaymentState state) { + TosspaymentsPayment tosspaymentsPayment = readById(tosspaymentsPaymentId); + tosspaymentsPayment.updateState(state); + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java new file mode 100644 index 0000000..8df692b --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java @@ -0,0 +1,45 @@ +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.Transactional; + +@RequiredArgsConstructor +@Service +public class TosspaymentsTransactionService { + + private final TosspaymentsPaymentService tosspaymentsPaymentService; + private final TosspaymentsPaymentResultService tosspaymentsPaymentResultService; + + @Transactional + public TosspaymentsPaymentResult applyTosspaymentsPaymentResult(TosspaymentsPaymentResult tosspaymentsPaymentResult, PaymentState paymentState) { + TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.readById(tosspaymentsPaymentResult.getTosspaymentsPayment().getId()); + tosspaymentsPayment.updateState(paymentState); + return tosspaymentsPaymentResultService.save(tosspaymentsPaymentResult); + } + + @Transactional + 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/internal/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java new file mode 100644 index 0000000..25c9ef2 --- /dev/null +++ b/internal/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/internal/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java new file mode 100644 index 0000000..24a7780 --- /dev/null +++ b/internal/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()); + } +} \ No newline at end of file diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java new file mode 100644 index 0000000..f0e5fe8 --- /dev/null +++ b/internal/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/internal/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java new file mode 100644 index 0000000..808d4c2 --- /dev/null +++ b/internal/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() + ); + } +} \ No newline at end of file diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java new file mode 100644 index 0000000..53ac691 --- /dev/null +++ b/internal/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()); + } +} \ No newline at end of file diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java new file mode 100644 index 0000000..cd86cd6 --- /dev/null +++ b/internal/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 + ); + } +} \ No newline at end of file diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java new file mode 100644 index 0000000..c42ac4b --- /dev/null +++ b/internal/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()); + } +} \ No newline at end of file diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java b/internal/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java new file mode 100644 index 0000000..0d6cdd2 --- /dev/null +++ b/internal/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() + ); + } +} \ No newline at end of file diff --git a/internal/src/main/resources/application.yml b/internal/src/main/resources/application.yml index e3e9608..a024ef2 100644 --- a/internal/src/main/resources/application.yml +++ b/internal/src/main/resources/application.yml @@ -9,6 +9,13 @@ spring: include: - domain - common + - external jackson: property-naming-strategy: SNAKE_CASE default-property-inclusion: non_null +--- +# local profile +spring: + config: + activate: + on-profile: local diff --git a/internal/src/main/resources/templates/payment-fail.html b/internal/src/main/resources/templates/payment-fail.html new file mode 100644 index 0000000..0002146 --- /dev/null +++ b/internal/src/main/resources/templates/payment-fail.html @@ -0,0 +1,235 @@ + + + + + + 결제 실패 - 꼬꼬면 + + + +
+
+

결제에 실패했습니다

+

결제 처리 중 문제가 발생했습니다.

+ +
+
+ 주문번호: + - +
+
+ 오류 코드: + - +
+
+ +
+ 알 수 없는 오류가 발생했습니다. +
+ +
+

자주 발생하는 오류와 해결방법

+
    +
  • PAY_PROCESS_CANCELED: 구매자가 결제를 취소했습니다.
  • +
  • PAY_PROCESS_ABORTED: 결제가 실패했습니다. 카드 정보나 계좌 잔액을 확인해주세요.
  • +
  • REJECT_CARD_COMPANY: 카드 정보에 문제가 있습니다. 카드번호, 유효기간, CVC를 다시 확인해주세요.
  • +
  • UNAUTHORIZED_KEY: 결제 키에 문제가 있습니다. 잠시 후 다시 시도해주세요.
  • +
  • NOT_REGISTERED_PAYMENT_WIDGET: 결제 UI 설정에 문제가 있습니다.
  • +
+
+ + +
+ + + + \ No newline at end of file diff --git a/internal/src/main/resources/templates/payment-refund.html b/internal/src/main/resources/templates/payment-refund.html new file mode 100644 index 0000000..2ec534b --- /dev/null +++ b/internal/src/main/resources/templates/payment-refund.html @@ -0,0 +1,390 @@ + + + + + + 결제 환불 - 꼬꼬면 + + + +
+

결제 환불 요청

+

환불하실 결제 정보를 입력해주세요

+ +
+

⚠️ 환불 전 확인사항

+
    +
  • 환불 요청 후 취소가 불가능합니다
  • +
  • 부분 환불은 지원하지 않습니다
  • +
  • 환불 완료까지 영업일 기준 3-5일이 소요될 수 있습니다
  • +
+
+ +
+ 환불이 성공적으로 처리되었습니다. +
+ +
+ 환불 처리 중 오류가 발생했습니다. +
+ +
+
+ + +
결제 완료 시 발급받은 Payment Key를 입력하세요
+
+ +
+ + +
+ +
+ + +
최대 200자까지 입력 가능합니다
+
+ +
+ + +
+
+ +
+
+

환불 처리 중입니다...

+
+
+ + + + diff --git a/internal/src/main/resources/templates/payment-success.html b/internal/src/main/resources/templates/payment-success.html new file mode 100644 index 0000000..ac81d6f --- /dev/null +++ b/internal/src/main/resources/templates/payment-success.html @@ -0,0 +1,280 @@ + + + + + + 결제 완료 - 꼬꼬면 토큰 + + + +
+
+

결제가 완료되었습니다!

+

꼬꼬면 토큰을 구매해주셔서 감사합니다.

+ +
+
+ 주문번호: + - +
+
+ 결제수단: + - +
+
+ 상품명: + 꼬꼬면 토큰 10개 +
+
+ 결제금액: + - +
+
+ 결제키: + - +
+
+ +
+

결제 승인 처리 중입니다...

+
+ + +
+ + + + diff --git a/internal/src/main/resources/templates/payment-test.html b/internal/src/main/resources/templates/payment-test.html new file mode 100644 index 0000000..d417271 --- /dev/null +++ b/internal/src/main/resources/templates/payment-test.html @@ -0,0 +1,211 @@ + + + + + + 꼬꼬면 토큰 결제 테스트 + + + + +
+

꼬꼬면 토큰 결제

+ +
+

주문 정보

+
+ 토큰 10개 + 1,000원 +
+
+ 총 결제금액 + 1,000원 +
+
+ + +
+ + +
+ + + + +
+

결제 처리 중...

+
+
+ + + + 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 new file mode 100644 index 0000000..3e66306 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java @@ -0,0 +1,99 @@ +package com.samhap.kokomen.payment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.samhap.kokomen.global.BaseControllerTest; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +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 java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class PaymentControllerTest extends BaseControllerTest { + + @MockitoBean + private PaymentFacadeService paymentFacadeService; + + @Test + void 결제를_승인한다() throws Exception { + // given + PaymentResponse mockResponse = new PaymentResponse( + "test_payment_key_001", + PaymentType.NORMAL, + "ORDER_20241201_001", + "꼬꼬면 토큰 10개", + "tvivarepublica", + "KRW", + "카드", + 10000L, + 10000L, + TosspaymentsStatus.DONE, + LocalDateTime.now(), + LocalDateTime.now(), + "test_transaction_key", + 9091L, + 909L, + 0L, + 0L, + true, + "{\"productType\":\"KOKOMEN_TOKEN\",\"quantity\":\"10\"}", + null, + null, + null, + "KR", + null, + null + ); + + when(paymentFacadeService.confirmPayment(any(ConfirmRequest.class))).thenReturn(mockResponse); + + String requestJson = """ + { + "payment_key": "test_payment_key_001", + "order_id": "ORDER_20241201_001", + "total_amount": 10000, + "order_name": "꼬꼬면 토큰 10개", + "member_id": 1, + "metadata": { + "productType": "KOKOMEN_TOKEN", + "quantity": "10" + }, + "service_type": "INTERVIEW" + } + """; + + // when & then + mockMvc.perform(post("/internal/v1/payments/confirm") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isOk()); + } + + @Test + void 결제를_취소한다() throws Exception { + // given + doNothing().when(paymentFacadeService).cancelPayment(any(CancelRequest.class)); + + String requestJson = """ + { + "payment_key": "test_payment_key_001", + "cancel_reason": "단순 변심" + } + """; + + // when & then + mockMvc.perform(post("/internal/v1/payments/cancel") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isNoContent()); + } +} diff --git a/internal/src/test/resources/application.yml b/internal/src/test/resources/application.yml index 71202a2..d4d7769 100644 --- a/internal/src/test/resources/application.yml +++ b/internal/src/test/resources/application.yml @@ -3,6 +3,7 @@ spring: include: - domain-test - common-test + - external-test main: lazy-initialization: true jackson: diff --git a/settings.gradle b/settings.gradle index 60e5ecc..75e11f0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,3 +4,4 @@ include 'common' include 'domain' include 'api' include 'internal' +include 'external'