Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(./gradlew:*)"
],
"deny": [],
"ask": []
}
}
174 changes: 174 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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: `<type>: <description>`
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
14 changes: 8 additions & 6 deletions api/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
= Kokomen API Guide
= Kokomen Payment API Guide
:doctype: book
:icons: font
:toc: left
Expand All @@ -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[]

Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -40,6 +43,32 @@ public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(Metho
.body(new ErrorResponse(message));
}

@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleException(Exception e) {
log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e);
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
6 changes: 5 additions & 1 deletion api/src/test/java/com/samhap/kokomen/global/BaseTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> redisTemplate;

@Autowired
private MySQLDatabaseCleaner mySQLDatabaseCleaner;

Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Loading