Skip to content

Commit 14eb5d1

Browse files
committed
add: 결제 관련 모듈 및 설정 파일 추가
1 parent 99890d6 commit 14eb5d1

32 files changed

Lines changed: 1196 additions & 0 deletions
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package com.loopers.interfaces.api;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.junit.jupiter.api.Assertions.assertAll;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import com.loopers.domain.user.User;
8+
import com.loopers.domain.user.User.Gender;
9+
import com.loopers.domain.user.UserRepository;
10+
import com.loopers.interfaces.api.user.UserV1Dto.SignUpRequest;
11+
import com.loopers.interfaces.api.user.UserV1Dto.UserResponse;
12+
import com.loopers.utils.DatabaseCleanUp;
13+
import java.util.function.Function;
14+
import org.junit.jupiter.api.AfterEach;
15+
import org.junit.jupiter.api.DisplayName;
16+
import org.junit.jupiter.api.Nested;
17+
import org.junit.jupiter.api.Test;
18+
import org.springframework.beans.factory.annotation.Autowired;
19+
import org.springframework.boot.test.context.SpringBootTest;
20+
import org.springframework.boot.test.web.client.TestRestTemplate;
21+
import org.springframework.core.ParameterizedTypeReference;
22+
import org.springframework.http.HttpEntity;
23+
import org.springframework.http.HttpMethod;
24+
import org.springframework.http.HttpStatus;
25+
import org.springframework.http.ResponseEntity;
26+
27+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
28+
class OrderV1ApiE2ETest {
29+
30+
private static final String ENDPOINT_POST = "/api/v1/users";
31+
private static final Function<String, String> ENDPOINT_GET = userId -> "/api/v1/users/" + userId;
32+
33+
private final TestRestTemplate testRestTemplate;
34+
private final UserRepository userRepository;
35+
private final DatabaseCleanUp databaseCleanUp;
36+
37+
@Autowired
38+
public OrderV1ApiE2ETest(
39+
TestRestTemplate testRestTemplate,
40+
UserRepository userRepository,
41+
DatabaseCleanUp databaseCleanUp
42+
) {
43+
this.testRestTemplate = testRestTemplate;
44+
this.userRepository = userRepository;
45+
this.databaseCleanUp = databaseCleanUp;
46+
}
47+
48+
@AfterEach
49+
void tearDown() throws InterruptedException {
50+
databaseCleanUp.truncateAllTables();
51+
}
52+
53+
@DisplayName("POST /api/v1/users")
54+
@Nested
55+
class Post {
56+
57+
@DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.")
58+
@Test
59+
void returnsCreatedUserInfo_whenValidRequestIsProvided() {
60+
// arrange
61+
SignUpRequest request = new SignUpRequest(
62+
"validId10", "valid@email.com", "2025-10-28", Gender.FEMALE);
63+
64+
// act
65+
ParameterizedTypeReference<ApiResponse<UserResponse>> responseType = new ParameterizedTypeReference<>() {};
66+
67+
ResponseEntity<ApiResponse<UserResponse>> response =
68+
testRestTemplate.exchange(
69+
ENDPOINT_POST, HttpMethod.POST, new HttpEntity<>(request), responseType);
70+
71+
// assert
72+
assertAll(
73+
() -> assertTrue(response.getStatusCode().is2xxSuccessful()),
74+
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
75+
76+
() -> assertThat(response.getBody().data().userId()).isEqualTo(request.userId()),
77+
() -> assertThat(response.getBody().data().email()).isEqualTo(request.email())
78+
);
79+
}
80+
81+
@DisplayName("회원 가입 시에 성별이 없을 경우, 400 Bad Request 응답을 반환한다.")
82+
@Test
83+
void returnsBadRequest_whenGenderIsNull() {
84+
// arrange
85+
SignUpRequest request = new SignUpRequest(
86+
"validId10", "valid@email.com", "2025-10-28", null);
87+
88+
// act
89+
ParameterizedTypeReference<ApiResponse<UserResponse>> responseType = new ParameterizedTypeReference<>() {};
90+
91+
ResponseEntity<ApiResponse<UserResponse>> response =
92+
testRestTemplate.exchange(
93+
ENDPOINT_POST, HttpMethod.POST, new HttpEntity<>(request), responseType);
94+
95+
// assert
96+
assertAll(
97+
() -> assertTrue(response.getStatusCode().is4xxClientError()),
98+
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)
99+
);
100+
}
101+
}
102+
103+
@DisplayName("Get /api/v1/users/{userId}")
104+
@Nested
105+
class Get {
106+
107+
@DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.")
108+
@Test
109+
void returnsUserResponse_whenValidIdIsProvided() {
110+
// arrange
111+
User user = userRepository.save(
112+
new User("validId10", "valid@email.com", "2025-10-28", Gender.FEMALE)
113+
);
114+
String requestUrl = ENDPOINT_GET.apply(user.getUserId());
115+
116+
// act
117+
ParameterizedTypeReference<ApiResponse<UserResponse>> responseType = new ParameterizedTypeReference<>() {};
118+
119+
ResponseEntity<ApiResponse<UserResponse>> response =
120+
testRestTemplate.exchange(
121+
requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
122+
123+
// assert
124+
assertAll(
125+
() -> assertTrue(response.getStatusCode().is2xxSuccessful()),
126+
127+
() -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()),
128+
() -> assertThat(response.getBody().data().email()).isEqualTo(user.getEmail())
129+
);
130+
}
131+
132+
@DisplayName("존재하지 않는 ID 로 조회할 경우, 404 Not Found 응답을 반환한다.")
133+
@Test
134+
void returnsNotFound_whenInvalidIdIsProvided() {
135+
// arrange
136+
String invalidUserId = "non-existent-user-id";
137+
String requestUrl = ENDPOINT_GET.apply(invalidUserId);
138+
139+
// act
140+
ParameterizedTypeReference<ApiResponse<UserResponse>> responseType = new ParameterizedTypeReference<>() {};
141+
142+
ResponseEntity<ApiResponse<UserResponse>> response =
143+
testRestTemplate.exchange(
144+
requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
145+
146+
// assert
147+
assertAll(
148+
() -> assertTrue(response.getStatusCode().is4xxClientError()),
149+
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)
150+
);
151+
}
152+
}
153+
}

apps/pg-simulator/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
## PG-Simulator (PaymentGateway)
2+
3+
### Description
4+
Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다.
5+
`local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다.
6+
- server port : 8082
7+
- actuator port : 8083
8+
9+
### Getting Started
10+
부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요.
11+
```shell
12+
./gradlew :apps:pg-simulator:bootRun
13+
```
14+
15+
API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다.
16+
- 결제 요청 API
17+
- 결제 정보 확인 `by transactionKey`
18+
- 결제 정보 목록 조회 `by orderId`
19+
20+
```http request
21+
### 결제 요청
22+
POST {{pg-simulator}}/api/v1/payments
23+
X-USER-ID: 135135
24+
Content-Type: application/json
25+
26+
{
27+
"orderId": "1351039135",
28+
"cardType": "SAMSUNG",
29+
"cardNo": "1234-5678-9814-1451",
30+
"amount" : "5000",
31+
"callbackUrl": "http://localhost:8080/api/v1/examples/callback"
32+
}
33+
34+
### 결제 정보 확인
35+
GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5
36+
X-USER-ID: 135135
37+
38+
### 주문에 엮인 결제 정보 조회
39+
GET {{pg-simulator}}/api/v1/payments?orderId=1351039135
40+
X-USER-ID: 135135
41+
42+
```

apps/pg-simulator/build.gradle.kts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
plugins {
2+
val kotlinVersion = "2.0.20"
3+
4+
id("org.jetbrains.kotlin.jvm") version(kotlinVersion)
5+
id("org.jetbrains.kotlin.kapt") version(kotlinVersion)
6+
id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion)
7+
id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion)
8+
}
9+
10+
kotlin {
11+
compilerOptions {
12+
jvmToolchain(21)
13+
freeCompilerArgs.addAll("-Xjsr305=strict")
14+
}
15+
}
16+
17+
dependencies {
18+
// add-ons
19+
implementation(project(":modules:jpa"))
20+
implementation(project(":modules:redis"))
21+
implementation(project(":supports:jackson"))
22+
implementation(project(":supports:logging"))
23+
implementation(project(":supports:monitoring"))
24+
25+
// kotlin
26+
implementation("org.jetbrains.kotlin:kotlin-reflect")
27+
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
28+
29+
// web
30+
implementation("org.springframework.boot:spring-boot-starter-web")
31+
implementation("org.springframework.boot:spring-boot-starter-actuator")
32+
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
33+
34+
// querydsl
35+
kapt("com.querydsl:querydsl-apt::jakarta")
36+
37+
// test-fixtures
38+
testImplementation(testFixtures(project(":modules:jpa")))
39+
testImplementation(testFixtures(project(":modules:redis")))
40+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.loopers
2+
3+
import jakarta.annotation.PostConstruct
4+
import org.springframework.boot.autoconfigure.SpringBootApplication
5+
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
6+
import org.springframework.boot.runApplication
7+
import org.springframework.scheduling.annotation.EnableAsync
8+
import java.util.TimeZone
9+
10+
@ConfigurationPropertiesScan
11+
@EnableAsync
12+
@SpringBootApplication
13+
class PaymentGatewayApplication {
14+
15+
@PostConstruct
16+
fun started() {
17+
// set timezone
18+
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
19+
}
20+
}
21+
22+
fun main(args: Array<String>) {
23+
runApplication<PaymentGatewayApplication>(*args)
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.loopers.application.payment
2+
3+
/**
4+
* 결제 주문 정보
5+
*
6+
* 결제는 주문에 대한 다수 트랜잭션으로 구성됩니다.
7+
*
8+
* @property orderId 주문 정보
9+
* @property transactions 주문에 엮인 트랜잭션 목록
10+
*/
11+
data class OrderInfo(
12+
val orderId: String,
13+
val transactions: List<TransactionInfo>,
14+
)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.loopers.application.payment
2+
3+
import com.loopers.domain.payment.Payment
4+
import com.loopers.domain.payment.PaymentEvent
5+
import com.loopers.domain.payment.PaymentEventPublisher
6+
import com.loopers.domain.payment.PaymentRelay
7+
import com.loopers.domain.payment.PaymentRepository
8+
import com.loopers.domain.payment.TransactionKeyGenerator
9+
import com.loopers.domain.user.UserInfo
10+
import com.loopers.support.error.CoreException
11+
import com.loopers.support.error.ErrorType
12+
import org.springframework.stereotype.Component
13+
import org.springframework.transaction.annotation.Transactional
14+
15+
@Component
16+
class PaymentApplicationService(
17+
private val paymentRepository: PaymentRepository,
18+
private val paymentEventPublisher: PaymentEventPublisher,
19+
private val paymentRelay: PaymentRelay,
20+
private val transactionKeyGenerator: TransactionKeyGenerator,
21+
) {
22+
companion object {
23+
private val RATE_LIMIT_EXCEEDED = (1..20)
24+
private val RATE_INVALID_CARD = (21..30)
25+
}
26+
27+
@Transactional
28+
fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo {
29+
command.validate()
30+
31+
val transactionKey = transactionKeyGenerator.generate()
32+
val payment = paymentRepository.save(
33+
Payment(
34+
transactionKey = transactionKey,
35+
userId = command.userId,
36+
orderId = command.orderId,
37+
cardType = command.cardType,
38+
cardNo = command.cardNo,
39+
amount = command.amount,
40+
callbackUrl = command.callbackUrl,
41+
),
42+
)
43+
44+
paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment))
45+
46+
return TransactionInfo.from(payment)
47+
}
48+
49+
@Transactional(readOnly = true)
50+
fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo {
51+
val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey)
52+
?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
53+
return TransactionInfo.from(payment)
54+
}
55+
56+
@Transactional(readOnly = true)
57+
fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo {
58+
val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId)
59+
if (payments.isEmpty()) {
60+
throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) 에 해당하는 결제건이 존재하지 않습니다.")
61+
}
62+
63+
return OrderInfo(
64+
orderId = orderId,
65+
transactions = payments.map { TransactionInfo.from(it) },
66+
)
67+
}
68+
69+
@Transactional
70+
fun handle(transactionKey: String) {
71+
val payment = paymentRepository.findByTransactionKey(transactionKey)
72+
?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
73+
74+
val rate = (1..100).random()
75+
when (rate) {
76+
in RATE_LIMIT_EXCEEDED -> payment.limitExceeded()
77+
in RATE_INVALID_CARD -> payment.invalidCard()
78+
else -> payment.approve()
79+
}
80+
paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment))
81+
}
82+
83+
fun notifyTransactionResult(transactionKey: String) {
84+
val payment = paymentRepository.findByTransactionKey(transactionKey)
85+
?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
86+
paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment))
87+
}
88+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.loopers.application.payment
2+
3+
import com.loopers.domain.payment.CardType
4+
import com.loopers.support.error.CoreException
5+
import com.loopers.support.error.ErrorType
6+
7+
object PaymentCommand {
8+
data class CreateTransaction(
9+
val userId: String,
10+
val orderId: String,
11+
val cardType: CardType,
12+
val cardNo: String,
13+
val amount: Long,
14+
val callbackUrl: String,
15+
) {
16+
fun validate() {
17+
if (amount <= 0L) {
18+
throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.")
19+
}
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)