Skip to content

Commit 8557d64

Browse files
authored
feat: PG 모듈 추가 (#24)
1 parent 5054a54 commit 8557d64

32 files changed

Lines changed: 1063 additions & 0 deletions

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+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.loopers.application.payment
2+
3+
import com.loopers.domain.payment.CardType
4+
import com.loopers.domain.payment.Payment
5+
import com.loopers.domain.payment.TransactionStatus
6+
7+
/**
8+
* 트랜잭션 정보
9+
*
10+
* @property transactionKey 트랜잭션 KEY
11+
* @property orderId 주문 ID
12+
* @property cardType 카드 종류
13+
* @property cardNo 카드 번호
14+
* @property amount 금액
15+
* @property status 처리 상태
16+
* @property reason 처리 사유
17+
*/
18+
data class TransactionInfo(
19+
val transactionKey: String,
20+
val orderId: String,
21+
val cardType: CardType,
22+
val cardNo: String,
23+
val amount: Long,
24+
val status: TransactionStatus,
25+
val reason: String?,
26+
) {
27+
companion object {
28+
fun from(payment: Payment): TransactionInfo =
29+
TransactionInfo(
30+
transactionKey = payment.transactionKey,
31+
orderId = payment.orderId,
32+
cardType = payment.cardType,
33+
cardNo = payment.cardNo,
34+
amount = payment.amount,
35+
status = payment.status,
36+
reason = payment.reason,
37+
)
38+
}
39+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.loopers.config.web
2+
3+
import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver
4+
import org.springframework.context.annotation.Configuration
5+
import org.springframework.web.method.support.HandlerMethodArgumentResolver
6+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
7+
8+
@Configuration
9+
class WebMvcConfig : WebMvcConfigurer {
10+
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver?>) {
11+
resolvers.add(UserInfoArgumentResolver())
12+
}
13+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.loopers.domain.payment
2+
3+
enum class CardType {
4+
SAMSUNG,
5+
KB,
6+
HYUNDAI,
7+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.loopers.domain.payment
2+
3+
import com.loopers.support.error.CoreException
4+
import com.loopers.support.error.ErrorType
5+
import jakarta.persistence.Column
6+
import jakarta.persistence.Entity
7+
import jakarta.persistence.EnumType
8+
import jakarta.persistence.Enumerated
9+
import jakarta.persistence.Id
10+
import jakarta.persistence.Index
11+
import jakarta.persistence.Table
12+
import java.time.LocalDateTime
13+
14+
@Entity
15+
@Table(
16+
name = "payments",
17+
indexes = [
18+
Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"),
19+
Index(name = "idx_user_order", columnList = "user_id, order_id"),
20+
Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true),
21+
]
22+
)
23+
class Payment(
24+
@Id
25+
@Column(name = "transaction_key", nullable = false, unique = true)
26+
val transactionKey: String,
27+
28+
@Column(name = "user_id", nullable = false)
29+
val userId: String,
30+
31+
@Column(name = "order_id", nullable = false)
32+
val orderId: String,
33+
34+
@Enumerated(EnumType.STRING)
35+
@Column(name = "card_type", nullable = false)
36+
val cardType: CardType,
37+
38+
@Column(name = "card_no", nullable = false)
39+
val cardNo: String,
40+
41+
@Column(name = "amount", nullable = false)
42+
val amount: Long,
43+
44+
@Column(name = "callback_url", nullable = false)
45+
val callbackUrl: String,
46+
) {
47+
@Enumerated(EnumType.STRING)
48+
@Column(name = "status", nullable = false)
49+
var status: TransactionStatus = TransactionStatus.PENDING
50+
private set
51+
52+
@Column(name = "reason", nullable = true)
53+
var reason: String? = null
54+
private set
55+
56+
@Column(name = "created_at", nullable = false)
57+
var createdAt: LocalDateTime = LocalDateTime.now()
58+
private set
59+
60+
@Column(name = "updated_at", nullable = false)
61+
var updatedAt: LocalDateTime = LocalDateTime.now()
62+
private set
63+
64+
fun approve() {
65+
if (status != TransactionStatus.PENDING) {
66+
throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.")
67+
}
68+
status = TransactionStatus.SUCCESS
69+
reason = "정상 승인되었습니다."
70+
}
71+
72+
fun invalidCard() {
73+
if (status != TransactionStatus.PENDING) {
74+
throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.")
75+
}
76+
status = TransactionStatus.FAILED
77+
reason = "잘못된 카드입니다. 다른 카드를 선택해주세요."
78+
}
79+
80+
fun limitExceeded() {
81+
if (status != TransactionStatus.PENDING) {
82+
throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.")
83+
}
84+
status = TransactionStatus.FAILED
85+
reason = "한도초과입니다. 다른 카드를 선택해주세요."
86+
}
87+
}

0 commit comments

Comments
 (0)