Skip to content

Commit 5c468d4

Browse files
authored
Merge pull request #34 from hyujikoh/feat/failure-ready
PG 모듈 적용
2 parents d9336ed + 28db030 commit 5c468d4

38 files changed

Lines changed: 1173 additions & 2 deletions

apps/commerce-api/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ dependencies {
99
// web
1010
implementation("org.springframework.boot:spring-boot-starter-web")
1111
implementation("org.springframework.boot:spring-boot-starter-actuator")
12+
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
1213
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
1314

14-
1515
// faker
1616
implementation("net.datafaker:datafaker:2.0.2")
1717

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.loopers.domain.payment;
2+
3+
import io.micrometer.core.annotation.Counted;
4+
import java.math.BigDecimal;
5+
import java.time.ZonedDateTime;
6+
7+
import com.loopers.domain.BaseEntity;
8+
9+
import lombok.AccessLevel;
10+
import lombok.Getter;
11+
import lombok.NoArgsConstructor;
12+
13+
import jakarta.persistence.*;
14+
15+
/**
16+
* @author hyunjikoh
17+
* @since 2025. 12. 2.
18+
*/
19+
@Entity
20+
@Table(name = "payments")
21+
@Getter
22+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
23+
public class PaymentEntity extends BaseEntity {
24+
25+
@Column(nullable = false, unique = true, length = 50)
26+
private String transactionKey;
27+
28+
@Column(nullable = false, length = 50)
29+
private String orderNumber;
30+
31+
@Column(nullable = false, precision = 10, scale = 2)
32+
private BigDecimal amount;
33+
34+
@Column(nullable = false, length = 20)
35+
private String cardType;
36+
37+
@Column(nullable = false, length = 20)
38+
private String cardNo;
39+
40+
@Enumerated(EnumType.STRING)
41+
@Column(nullable = false, length = 20)
42+
private PaymentStatus paymentStatus;
43+
44+
@Column(nullable = false, length = 255)
45+
private String callbackUrl;
46+
47+
// TODO : TransactionStatus 로 변경 필요
48+
@Column(nullable = true, length = 20)
49+
private String transactionStatus;
50+
51+
@Column(length = 20)
52+
private String failureReason;
53+
54+
@Column(name = "completed_at")
55+
private ZonedDateTime completedAt;
56+
57+
// → PG에 실제 결제 요청을 보낸 시각
58+
@Column(name = "requested_at", nullable = false)
59+
private ZonedDateTime requestedAt;
60+
61+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.loopers.domain.payment;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
/**
7+
* @author hyunjikoh
8+
* @since 2025. 12. 2.
9+
*/
10+
@Getter
11+
@AllArgsConstructor
12+
public enum PaymentStatus {
13+
PENDING("PG 요청 완료, 콜백 대기"), // PG 요청 완료, 콜백 대기
14+
COMPLETED("결제 완료"), // 결제 성공
15+
FAILED("결제 실패"), // 결제 실패
16+
REFUNDED("결제 환불"), // 결제 환불
17+
CANCEL("결제 취소"), // 결제 취소
18+
TIMEOUT("결제 시간 만료"); // 콜백 미수신 타임아웃
19+
20+
private final String description;
21+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.loopers.domain.payment;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
/**
7+
* @author hyunjikoh
8+
* @since 2025. 12. 2.
9+
*/
10+
@AllArgsConstructor
11+
@Getter
12+
public enum PaymentType {
13+
CARD("카드 결제"),
14+
REFUND("환불");
15+
16+
private final String description;
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.loopers.interfaces.api.payment.client;
2+
3+
/**
4+
* @author hyunjikoh
5+
* @since 2025. 12. 2.
6+
*/
7+
public class PgClient {
8+
}

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+
}

0 commit comments

Comments
 (0)