From 1eed3a5e820338e1a5aebd544ce440770b1f2315 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Sat, 16 Aug 2025 17:55:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?:apps:pg-simulator/=20kotlin-template=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pg-simulator/README.md | 42 ++++++ apps/pg-simulator/build.gradle.kts | 40 ++++++ .../com/loopers/PaymentGatewayApplication.kt | 24 ++++ .../loopers/application/payment/OrderInfo.kt | 14 ++ .../payment/PaymentApplicationService.kt | 88 ++++++++++++ .../application/payment/PaymentCommand.kt | 22 +++ .../application/payment/TransactionInfo.kt | 39 +++++ .../com/loopers/config/web/WebMvcConfig.kt | 13 ++ .../com/loopers/domain/payment/CardType.kt | 7 + .../com/loopers/domain/payment/Payment.kt | 87 +++++++++++ .../loopers/domain/payment/PaymentEvent.kt | 28 ++++ .../domain/payment/PaymentEventPublisher.kt | 6 + .../loopers/domain/payment/PaymentRelay.kt | 7 + .../domain/payment/PaymentRepository.kt | 8 ++ .../domain/payment/TransactionKeyGenerator.kt | 20 +++ .../domain/payment/TransactionStatus.kt | 7 + .../com/loopers/domain/user/UserInfo.kt | 8 ++ .../payment/PaymentCoreEventPublisher.kt | 19 +++ .../payment/PaymentCoreRelay.kt | 21 +++ .../payment/PaymentCoreRepository.kt | 32 +++++ .../payment/PaymentJpaRepository.kt | 9 ++ .../interfaces/api/ApiControllerAdvice.kt | 119 +++++++++++++++ .../com/loopers/interfaces/api/ApiResponse.kt | 32 +++++ .../UserInfoArgumentResolver.kt | 32 +++++ .../interfaces/api/payment/PaymentApi.kt | 60 ++++++++ .../interfaces/api/payment/PaymentDto.kt | 136 ++++++++++++++++++ .../event/payment/PaymentEventListener.kt | 28 ++++ .../loopers/support/error/CoreException.kt | 6 + .../com/loopers/support/error/ErrorType.kt | 11 ++ .../src/main/resources/application.yml | 77 ++++++++++ 30 files changed, 1042 insertions(+) create mode 100644 apps/pg-simulator/README.md create mode 100644 apps/pg-simulator/build.gradle.kts create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt create mode 100644 apps/pg-simulator/src/main/resources/application.yml diff --git a/apps/pg-simulator/README.md b/apps/pg-simulator/README.md new file mode 100644 index 0000000..1186426 --- /dev/null +++ b/apps/pg-simulator/README.md @@ -0,0 +1,42 @@ +## PG-Simulator (PaymentGateway) + +### Description +Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다. +`local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다. +- server port : 8082 +- actuator port : 8083 + +### Getting Started +부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요. +```shell +./gradlew :apps:pg-simulator:bootRun +``` + +API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다. +- 결제 요청 API +- 결제 정보 확인 `by transactionKey` +- 결제 정보 목록 조회 `by orderId` + +```http request +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 + +``` \ No newline at end of file diff --git a/apps/pg-simulator/build.gradle.kts b/apps/pg-simulator/build.gradle.kts new file mode 100644 index 0000000..653d549 --- /dev/null +++ b/apps/pg-simulator/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + val kotlinVersion = "2.0.20" + + id("org.jetbrains.kotlin.jvm") version(kotlinVersion) + id("org.jetbrains.kotlin.kapt") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion) +} + +kotlin { + compilerOptions { + jvmToolchain(21) + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // querydsl + kapt("com.querydsl:querydsl-apt::jakarta") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt new file mode 100644 index 0000000..05595d1 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt @@ -0,0 +1,24 @@ +package com.loopers + +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync +import java.util.TimeZone + +@ConfigurationPropertiesScan +@EnableAsync +@SpringBootApplication +class PaymentGatewayApplication { + + @PostConstruct + fun started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt new file mode 100644 index 0000000..7e04d1c --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt @@ -0,0 +1,14 @@ +package com.loopers.application.payment + +/** + * 결제 주문 정보 + * + * 결제는 주문에 대한 다수 트랜잭션으로 구성됩니다. + * + * @property orderId 주문 정보 + * @property transactions 주문에 엮인 트랜잭션 목록 + */ +data class OrderInfo( + val orderId: String, + val transactions: List, +) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt new file mode 100644 index 0000000..9a5ebdc --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt @@ -0,0 +1,88 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import com.loopers.domain.payment.PaymentRelay +import com.loopers.domain.payment.PaymentRepository +import com.loopers.domain.payment.TransactionKeyGenerator +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class PaymentApplicationService( + private val paymentRepository: PaymentRepository, + private val paymentEventPublisher: PaymentEventPublisher, + private val paymentRelay: PaymentRelay, + private val transactionKeyGenerator: TransactionKeyGenerator, +) { + companion object { + private val RATE_LIMIT_EXCEEDED = (1..20) + private val RATE_INVALID_CARD = (21..30) + } + + @Transactional + fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo { + command.validate() + + val transactionKey = transactionKeyGenerator.generate() + val payment = paymentRepository.save( + Payment( + transactionKey = transactionKey, + userId = command.userId, + orderId = command.orderId, + cardType = command.cardType, + cardNo = command.cardNo, + amount = command.amount, + callbackUrl = command.callbackUrl, + ), + ) + + paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment)) + + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo { + val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo { + val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId) + if (payments.isEmpty()) { + throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) 에 해당하는 결제건이 존재하지 않습니다.") + } + + return OrderInfo( + orderId = orderId, + transactions = payments.map { TransactionInfo.from(it) }, + ) + } + + @Transactional + fun handle(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + + val rate = (1..100).random() + when (rate) { + in RATE_LIMIT_EXCEEDED -> payment.limitExceeded() + in RATE_INVALID_CARD -> payment.invalidCard() + else -> payment.approve() + } + paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment)) + } + + fun notifyTransactionResult(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment)) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt new file mode 100644 index 0000000..01d8ae4 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt @@ -0,0 +1,22 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentCommand { + data class CreateTransaction( + val userId: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + fun validate() { + if (amount <= 0L) { + throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.") + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt new file mode 100644 index 0000000..5c21e51 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt @@ -0,0 +1,39 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.TransactionStatus + +/** + * 트랜잭션 정보 + * + * @property transactionKey 트랜잭션 KEY + * @property orderId 주문 ID + * @property cardType 카드 종류 + * @property cardNo 카드 번호 + * @property amount 금액 + * @property status 처리 상태 + * @property reason 처리 사유 + */ +data class TransactionInfo( + val transactionKey: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val status: TransactionStatus, + val reason: String?, +) { + companion object { + fun from(payment: Payment): TransactionInfo = + TransactionInfo( + transactionKey = payment.transactionKey, + orderId = payment.orderId, + cardType = payment.cardType, + cardNo = payment.cardNo, + amount = payment.amount, + status = payment.status, + reason = payment.reason, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt new file mode 100644 index 0000000..8aec9dc --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt @@ -0,0 +1,13 @@ +package com.loopers.config.web + +import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(UserInfoArgumentResolver()) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt new file mode 100644 index 0000000..55008a9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class CardType { + SAMSUNG, + KB, + HYUNDAI, +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt new file mode 100644 index 0000000..cfc2386 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt @@ -0,0 +1,87 @@ +package com.loopers.domain.payment + +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table( + name = "payments", + indexes = [ + Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"), + Index(name = "idx_user_order", columnList = "user_id, order_id"), + Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true), + ] +) +class Payment( + @Id + @Column(name = "transaction_key", nullable = false, unique = true) + val transactionKey: String, + + @Column(name = "user_id", nullable = false) + val userId: String, + + @Column(name = "order_id", nullable = false) + val orderId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = false) + val cardType: CardType, + + @Column(name = "card_no", nullable = false) + val cardNo: String, + + @Column(name = "amount", nullable = false) + val amount: Long, + + @Column(name = "callback_url", nullable = false) + val callbackUrl: String, +) { + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: TransactionStatus = TransactionStatus.PENDING + private set + + @Column(name = "reason", nullable = true) + var reason: String? = null + private set + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now() + private set + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + private set + + fun approve() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.") + } + status = TransactionStatus.SUCCESS + reason = "정상 승인되었습니다." + } + + fun invalidCard() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "잘못된 카드입니다. 다른 카드를 선택해주세요." + } + + fun limitExceeded() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "한도초과입니다. 다른 카드를 선택해주세요." + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt new file mode 100644 index 0000000..8e495b2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt @@ -0,0 +1,28 @@ +package com.loopers.domain.payment + +object PaymentEvent { + data class PaymentCreated( + val transactionKey: String, + ) { + companion object { + fun from(payment: Payment): PaymentCreated = PaymentCreated(transactionKey = payment.transactionKey) + } + } + + data class PaymentHandled( + val transactionKey: String, + val status: TransactionStatus, + val reason: String?, + val callbackUrl: String, + ) { + companion object { + fun from(payment: Payment): PaymentHandled = + PaymentHandled( + transactionKey = payment.transactionKey, + status = payment.status, + reason = payment.reason, + callbackUrl = payment.callbackUrl, + ) + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt new file mode 100644 index 0000000..251c683 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt @@ -0,0 +1,6 @@ +package com.loopers.domain.payment + +interface PaymentEventPublisher { + fun publish(event: PaymentEvent.PaymentCreated) + fun publish(event: PaymentEvent.PaymentHandled) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt new file mode 100644 index 0000000..e622899 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +import com.loopers.application.payment.TransactionInfo + +interface PaymentRelay { + fun notify(callbackUrl: String, transactionInfo: TransactionInfo) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt new file mode 100644 index 0000000..c1173c0 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.payment + +interface PaymentRepository { + fun save(payment: Payment): Payment + fun findByTransactionKey(transactionKey: String): Payment? + fun findByTransactionKey(userId: String, transactionKey: String): Payment? + fun findByOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt new file mode 100644 index 0000000..c8703a7 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt @@ -0,0 +1,20 @@ +package com.loopers.domain.payment + +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Component +class TransactionKeyGenerator { + companion object { + private const val KEY_TRANSACTION = "TR" + private val DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + } + + fun generate(): String { + val now = LocalDateTime.now() + val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6) + return "${DATETIME_FORMATTER.format(now)}:$KEY_TRANSACTION:$uuid" + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt new file mode 100644 index 0000000..0c94bcf --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class TransactionStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt new file mode 100644 index 0000000..c51e660 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.user + +/** + * user 정보 + * + * @param userId 유저 식별자 + */ +data class UserInfo(val userId: String) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt new file mode 100644 index 0000000..7155163 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class PaymentCoreEventPublisher( + private val applicationEventPublisher: ApplicationEventPublisher, +) : PaymentEventPublisher { + override fun publish(event: PaymentEvent.PaymentCreated) { + applicationEventPublisher.publishEvent(event) + } + + override fun publish(event: PaymentEvent.PaymentHandled) { + applicationEventPublisher.publishEvent(event) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt new file mode 100644 index 0000000..ffd643c --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.payment + +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.PaymentRelay +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +@Component +class PaymentCoreRelay : PaymentRelay { + companion object { + private val logger = LoggerFactory.getLogger(PaymentCoreRelay::class.java) + private val restTemplate = RestTemplate() + } + + override fun notify(callbackUrl: String, transactionInfo: TransactionInfo) { + runCatching { + restTemplate.postForEntity(callbackUrl, transactionInfo, Any::class.java) + }.onFailure { e -> logger.error("콜백 호출을 실패했습니다. {}", e.message, e) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt new file mode 100644 index 0000000..cf521c4 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import kotlin.jvm.optionals.getOrNull + +@Component +class PaymentCoreRepository( + private val paymentJpaRepository: PaymentJpaRepository, +) : PaymentRepository { + @Transactional + override fun save(payment: Payment): Payment { + return paymentJpaRepository.save(payment) + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(transactionKey: String): Payment? { + return paymentJpaRepository.findById(transactionKey).getOrNull() + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(userId: String, transactionKey: String): Payment? { + return paymentJpaRepository.findByUserIdAndTransactionKey(userId, transactionKey) + } + + override fun findByOrderId(userId: String, orderId: String): List { + return paymentJpaRepository.findByUserIdAndOrderId(userId, orderId) + .sortedByDescending { it.updatedAt } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt new file mode 100644 index 0000000..a5ea328 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import org.springframework.data.jpa.repository.JpaRepository + +interface PaymentJpaRepository : JpaRepository { + fun findByUserIdAndTransactionKey(userId: String, transactionKey: String): Payment? + fun findByUserIdAndOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt new file mode 100644 index 0000000..434a229 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api + +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.server.ServerWebInputException +import org.springframework.web.servlet.resource.NoResourceFoundException +import kotlin.collections.joinToString +import kotlin.jvm.java +import kotlin.text.isNotEmpty +import kotlin.text.toRegex + +@RestControllerAdvice +class ApiControllerAdvice { + private val log = LoggerFactory.getLogger(ApiControllerAdvice::class.java) + + @ExceptionHandler + fun handle(e: CoreException): ResponseEntity> { + log.warn("CoreException : {}", e.customMessage ?: e.message, e) + return failureResponse(errorType = e.errorType, errorMessage = e.customMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: MethodArgumentTypeMismatchException): ResponseEntity> { + val name = e.name + val type = e.requiredType?.simpleName ?: "unknown" + val value = e.value ?: "null" + val message = "요청 파라미터 '$name' (타입: $type)의 값 '$value'이(가) 잘못되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: MissingServletRequestParameterException): ResponseEntity> { + val name = e.parameterName + val type = e.parameterType + val message = "필수 요청 파라미터 '$name' (타입: $type)가 누락되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: HttpMessageNotReadableException): ResponseEntity> { + val errorMessage = when (val rootCause = e.rootCause) { + is InvalidFormatException -> { + val fieldName = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + + val valueIndicationMessage = when { + rootCause.targetType.isEnum -> { + val enumClass = rootCause.targetType + val enumValues = enumClass.enumConstants.joinToString(", ") { it.toString() } + "사용 가능한 값 : [$enumValues]" + } + + else -> "" + } + + val expectedType = rootCause.targetType.simpleName + val value = rootCause.value + + "필드 '$fieldName'의 값 '$value'이(가) 예상 타입($expectedType)과 일치하지 않습니다. $valueIndicationMessage" + } + + is MismatchedInputException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필수 필드 '$fieldPath'이(가) 누락되었습니다." + } + + is JsonMappingException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필드 '$fieldPath'에서 JSON 매핑 오류가 발생했습니다: ${rootCause.originalMessage}" + } + + else -> "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요." + } + + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = errorMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: ServerWebInputException): ResponseEntity> { + fun extractMissingParameter(message: String): String { + val regex = "'(.+?)'".toRegex() + return regex.find(message)?.groupValues?.get(1) ?: "" + } + + val missingParams = extractMissingParameter(e.reason ?: "") + return if (missingParams.isNotEmpty()) { + failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = "필수 요청 값 \'$missingParams\'가 누락되었습니다.") + } else { + failureResponse(errorType = ErrorType.BAD_REQUEST) + } + } + + @ExceptionHandler + fun handleNotFound(e: NoResourceFoundException): ResponseEntity> { + return failureResponse(errorType = ErrorType.NOT_FOUND) + } + + @ExceptionHandler + fun handle(e: Throwable): ResponseEntity> { + log.error("Exception : {}", e.message, e) + val errorType = ErrorType.INTERNAL_ERROR + return failureResponse(errorType = errorType) + } + + private fun failureResponse(errorType: ErrorType, errorMessage: String? = null): ResponseEntity> = + ResponseEntity( + ApiResponse.fail(errorCode = errorType.code, errorMessage = errorMessage ?: errorType.message), + errorType.status, + ) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt new file mode 100644 index 0000000..f5c38ab --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api + +data class ApiResponse( + val meta: Metadata, + val data: T?, +) { + data class Metadata( + val result: Result, + val errorCode: String?, + val message: String?, + ) { + enum class Result { SUCCESS, FAIL } + + companion object { + fun success() = Metadata(Result.SUCCESS, null, null) + + fun fail(errorCode: String, errorMessage: String) = Metadata(Result.FAIL, errorCode, errorMessage) + } + } + + companion object { + fun success(): ApiResponse = ApiResponse(Metadata.success(), null) + + fun success(data: T? = null) = ApiResponse(Metadata.success(), data) + + fun fail(errorCode: String, errorMessage: String): ApiResponse = + ApiResponse( + meta = Metadata.fail(errorCode = errorCode, errorMessage = errorMessage), + data = null, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt new file mode 100644 index 0000000..9ef6c25 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.argumentresolver + +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class UserInfoArgumentResolver: HandlerMethodArgumentResolver { + companion object { + private const val KEY_USER_ID = "X-USER-ID" + } + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return UserInfo::class.java.isAssignableFrom(parameter.parameterType) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): UserInfo { + val userId = webRequest.getHeader(KEY_USER_ID) + ?: throw CoreException(ErrorType.BAD_REQUEST, "유저 ID 헤더는 필수입니다.") + + return UserInfo(userId) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt new file mode 100644 index 0000000..22d5cbe --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.interfaces.api.ApiResponse +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/payments") +class PaymentApi( + private val paymentApplicationService: PaymentApplicationService, +) { + @PostMapping + fun request( + userInfo: UserInfo, + @RequestBody request: PaymentDto.PaymentRequest, + ): ApiResponse { + request.validate() + + // 100ms ~ 500ms 지연 + Thread.sleep((100..500L).random()) + + // 40% 확률로 요청 실패 + if ((1..100).random() <= 40) { + throw CoreException(ErrorType.INTERNAL_ERROR, "현재 서버가 불안정합니다. 잠시 후 다시 시도해주세요.") + } + + return paymentApplicationService.createTransaction(request.toCommand(userInfo.userId)) + .let { PaymentDto.TransactionResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping("/{transactionKey}") + fun getTransaction( + userInfo: UserInfo, + @PathVariable("transactionKey") transactionKey: String, + ): ApiResponse { + return paymentApplicationService.getTransactionDetailInfo(userInfo, transactionKey) + .let { PaymentDto.TransactionDetailResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping + fun getTransactionsByOrder( + userInfo: UserInfo, + @RequestParam("orderId", required = false) orderId: String, + ): ApiResponse { + return paymentApplicationService.findTransactionsByOrderId(userInfo, orderId) + .let { PaymentDto.OrderResponse.from(it) } + .let { ApiResponse.success(it) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt new file mode 100644 index 0000000..52a00b1 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt @@ -0,0 +1,136 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.OrderInfo +import com.loopers.application.payment.PaymentCommand +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.TransactionStatus +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentDto { + data class PaymentRequest( + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + companion object { + private val REGEX_CARD_NO = Regex("^\\d{4}-\\d{4}-\\d{4}-\\d{4}$") + private const val PREFIX_CALLBACK_URL = "http://localhost:8080" + } + + fun validate() { + if (orderId.isBlank() || orderId.length < 6) { + throw CoreException(ErrorType.BAD_REQUEST, "주문 ID는 6자리 이상 문자열이어야 합니다.") + } + if (!REGEX_CARD_NO.matches(cardNo)) { + throw CoreException(ErrorType.BAD_REQUEST, "카드 번호는 xxxx-xxxx-xxxx-xxxx 형식이어야 합니다.") + } + if (amount <= 0) { + throw CoreException(ErrorType.BAD_REQUEST, "결제금액은 양의 정수여야 합니다.") + } + if (!callbackUrl.startsWith(PREFIX_CALLBACK_URL)) { + throw CoreException(ErrorType.BAD_REQUEST, "콜백 URL 은 $PREFIX_CALLBACK_URL 로 시작해야 합니다.") + } + } + + fun toCommand(userId: String): PaymentCommand.CreateTransaction = + PaymentCommand.CreateTransaction( + userId = userId, + orderId = orderId, + cardType = cardType.toCardType(), + cardNo = cardNo, + amount = amount, + callbackUrl = callbackUrl, + ) + } + + data class TransactionDetailResponse( + val transactionKey: String, + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionDetailResponse = + TransactionDetailResponse( + transactionKey = transactionInfo.transactionKey, + orderId = transactionInfo.orderId, + cardType = CardTypeDto.from(transactionInfo.cardType), + cardNo = transactionInfo.cardNo, + amount = transactionInfo.amount, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class TransactionResponse( + val transactionKey: String, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionResponse = + TransactionResponse( + transactionKey = transactionInfo.transactionKey, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class OrderResponse( + val orderId: String, + val transactions: List, + ) { + companion object { + fun from(orderInfo: OrderInfo): OrderResponse = + OrderResponse( + orderId = orderInfo.orderId, + transactions = orderInfo.transactions.map { TransactionResponse.from(it) }, + ) + } + } + + enum class CardTypeDto { + SAMSUNG, + KB, + HYUNDAI, + ; + + fun toCardType(): CardType = when (this) { + SAMSUNG -> CardType.SAMSUNG + KB -> CardType.KB + HYUNDAI -> CardType.HYUNDAI + } + + companion object { + fun from(cardType: CardType) = when (cardType) { + CardType.SAMSUNG -> SAMSUNG + CardType.KB -> KB + CardType.HYUNDAI -> HYUNDAI + } + } + } + + enum class TransactionStatusResponse { + PENDING, + SUCCESS, + FAILED, + ; + + companion object { + fun from(transactionStatus: TransactionStatus) = when (transactionStatus) { + TransactionStatus.PENDING -> PENDING + TransactionStatus.SUCCESS -> SUCCESS + TransactionStatus.FAILED -> FAILED + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt new file mode 100644 index 0000000..2413228 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt @@ -0,0 +1,28 @@ +package com.loopers.interfaces.event.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.domain.payment.PaymentEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class PaymentEventListener( + private val paymentApplicationService: PaymentApplicationService, +) { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentCreated) { + val thresholdMillis = (1000L..5000L).random() + Thread.sleep(thresholdMillis) + + paymentApplicationService.handle(event.transactionKey) + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentHandled) { + paymentApplicationService.notifyTransactionResult(transactionKey = event.transactionKey) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt new file mode 100644 index 0000000..120f7fc --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt @@ -0,0 +1,6 @@ +package com.loopers.support.error + +class CoreException( + val errorType: ErrorType, + val customMessage: String? = null, +) : RuntimeException(customMessage ?: errorType.message) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt new file mode 100644 index 0000000..e0799a5 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt @@ -0,0 +1,11 @@ +package com.loopers.support.error + +import org.springframework.http.HttpStatus + +enum class ErrorType(val status: HttpStatus, val code: String, val message: String) { + /** 범용 에러 */ + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase, "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.reasonPhrase, "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.reasonPhrase, "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.reasonPhrase, "이미 존재하는 리소스입니다."), +} diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml new file mode 100644 index 0000000..addf0e2 --- /dev/null +++ b/apps/pg-simulator/src/main/resources/application.yml @@ -0,0 +1,77 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +datasource: + mysql-jpa: + main: + jdbc-url: jdbc:mysql://localhost:3306/paymentgateway + +springdoc: + use-fqn: true + swagger-ui: + path: /swagger-ui.html + +--- +spring: + config: + activate: + on-profile: local, test + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: dev + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file From 92eefdf8af1820ad7fcb4e4c824bd36a6b69a0b6 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Sat, 16 Aug 2025 17:55:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?:apps:pg-simulator/=20kotlin-template=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index dd884d8..18a81f1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", + ":apps:pg-simulator", ":modules:jpa", ":modules:redis", ":supports:jackson", From 3144c71fbe60e79ff2cab2c7bd65e34e6925be01 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Sat, 16 Aug 2025 17:55:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?http=20=EC=9A=94=EC=B2=AD=20=EC=98=88?= =?UTF-8?q?=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/http-client.env.json | 3 ++- http/pg-simulator/payments.http | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 http/pg-simulator/payments.http diff --git a/http/http-client.env.json b/http/http-client.env.json index 0db34e6..99e141b 100644 --- a/http/http-client.env.json +++ b/http/http-client.env.json @@ -1,5 +1,6 @@ { "local": { - "commerce-api": "http://localhost:8080" + "commerce-api": "http://localhost:8080", + "pg-simulator": "http://localhost:8082" } } diff --git a/http/pg-simulator/payments.http b/http/pg-simulator/payments.http new file mode 100644 index 0000000..096dd2f --- /dev/null +++ b/http/pg-simulator/payments.http @@ -0,0 +1,20 @@ +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135