From f6ef6e21ec251668ca8a660da2555f97f5af99c9 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 15 Oct 2025 19:22:07 +0900 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=20Dockerfile=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94=20=EB=B0=8F=20=EB=B3=84=EB=8F=84=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 28 ++++------------------------ build.sh | 6 ++++++ 2 files changed, 10 insertions(+), 24 deletions(-) create mode 100644 build.sh diff --git a/Dockerfile b/Dockerfile index 3e9dc9dc..048fc5b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,9 @@ -FROM gradle:8-jdk17 AS dependencies -WORKDIR /app - -COPY gradlew settings.gradle build.gradle.kts /app/ -COPY gradle /app/gradle -COPY service/build.gradle.kts /app/service/ -COPY tosspay-mock/build.gradle.kts /app/tosspay-mock/ -COPY common/log/build.gradle.kts /app/common/log/ -COPY common/persistence/build.gradle.kts /app/common/persistence/ -COPY common/types/build.gradle.kts /app/common/types/ -COPY common/utils/build.gradle.kts /app/common/utils/ -COPY common/web/build.gradle.kts /app/common/web/ - -RUN ./gradlew dependencies --no-daemon - -FROM dependencies AS builder -WORKDIR /app - -COPY . . - -RUN ./gradlew :service:bootjar --no-daemon - FROM amazoncorretto:17 + WORKDIR /app + +COPY service/build/libs/service.jar app.jar + EXPOSE 8080 -COPY --from=builder /app/service/build/libs/*.jar app.jar - ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 00000000..49fadae5 --- /dev/null +++ b/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +IMAGE_NAME="roomescape-backend" +IMAGE_TAG=$1 + +./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/roome \ No newline at end of file -- 2.47.2 From e3cfb6c78bffe6bfc85d6c0733ccb03d682f1ba2 Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 15 Oct 2025 19:22:25 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20pay?= =?UTF-8?q?ment=20API=20=EA=B8=B0=EB=B3=B8=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/src/main/resources/application.yaml b/service/src/main/resources/application.yaml index d614867c..4565a7bc 100644 --- a/service/src/main/resources/application.yaml +++ b/service/src/main/resources/application.yaml @@ -28,7 +28,7 @@ management: show-details: always payment: - api-base-url: ${PAYMENT_SERVER_ENDPOINT:https://api.tosspayments.com} + api-base-url: ${PAYMENT_SERVER_ENDPOINT:http://localhost:8000} springdoc: swagger-ui: -- 2.47.2 From e6cfd7b68be02e0d6f43b688cbfa35f34ff9833c Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 10:24:34 +0900 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 build.sh diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index 49fadae5..edbf302a --- a/build.sh +++ b/build.sh @@ -3,4 +3,4 @@ IMAGE_NAME="roomescape-backend" IMAGE_TAG=$1 -./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/roome \ No newline at end of file +./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push \ No newline at end of file -- 2.47.2 From 747245d9acd46ce11c7b590f6f2e9315c82aad36 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 10:25:31 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=20k6=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 초기 셋업 데이터 로드는 로컬에서 받아오도록 수정 - tag 추가로 ID별 구분이 아닌 API별 구분 집계 --- test-scripts/common.js | 8 ++++---- test-scripts/create-reservation-scripts.js | 24 +++++++++++++--------- test-scripts/create-schedule-scripts.js | 9 ++++---- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/test-scripts/common.js b/test-scripts/common.js index 3f3e6098..05e3a54d 100644 --- a/test-scripts/common.js +++ b/test-scripts/common.js @@ -22,7 +22,7 @@ export function parseIdToString(response) { } export function maxIterations() { - const maxIterationsRes = http.get(`${BASE_URL}/tests/max-iterations`) + const maxIterationsRes = http.get(`http://localhost:8080/tests/max-iterations`) if (maxIterationsRes.status !== 200) { throw new Error('max-iterations 조회 실패') } @@ -57,7 +57,7 @@ export function login(account, password, principalType) { password: password, principalType: principalType }) - const params = { headers: { 'Content-Type': 'application/json' } } + const params = { headers: { 'Content-Type': 'application/json' }, tags: { name: '/auth/login' } } const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params) @@ -78,7 +78,7 @@ export function login(account, password, principalType) { } } -export function getHeaders(token) { +export function getHeaders(token, endpoint) { const headers = { 'Content-Type': 'application/json', }; @@ -87,5 +87,5 @@ export function getHeaders(token) { headers['Authorization'] = `Bearer ${token}`; } - return { headers: headers }; + return { headers: headers, tags: { name: endpoint } }; } diff --git a/test-scripts/create-reservation-scripts.js b/test-scripts/create-reservation-scripts.js index d5c26aca..c51e5aa5 100644 --- a/test-scripts/create-reservation-scripts.js +++ b/test-scripts/create-reservation-scripts.js @@ -15,10 +15,13 @@ export const options = { scenarios: { user_reservation: { executor: 'ramping-vus', - startVUs: 1500, + startVUs: 0, stages: [ - { duration: '10m', target: 1500 }, - { duration: '1m', target: 0 } + { duration: '3m', target: 500 }, + { duration: '2m', target: 1000 }, + { duration: '2m', target: 1500 }, + { duration: '3m', target: 1500 }, + { duration: '3m', target: 0 }, ] } }, @@ -92,7 +95,8 @@ export default function (data) { while (searchTrial < 5) { storeId = randomItem(stores).storeId targetDate = randomDayBetween(1, 7) - const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) + const params = getHeaders(accessToken, "/stores/${storeId}/schedules?date=${date}") + const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`, params) const result = check(res, {'일정 조회 성공': (r) => r.status === 200}) if (result !== true) { continue @@ -118,7 +122,7 @@ export default function (data) { const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate) randomThemesForFetchDetail.forEach(id => { - http.get(`${BASE_URL}/themes/${id}`) + http.get(`${BASE_URL}/themes/${id}`, getHeaders(accessToken, "/themes/${id}")) sleep(10) }) }) @@ -137,11 +141,11 @@ export default function (data) { let isScheduleHeld = false group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () { - const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken)) + const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken, "/schedules/${id}/hold")) const body = JSON.parse(holdRes.body) if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) { - const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`) + const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`, { tag: { name: "/themes/${id}"}}) selectedThemeInfo = parseIdToString(themeInfoRes).data isScheduleHeld = true } else { @@ -160,7 +164,7 @@ export default function (data) { group(`예약 정보 입력 페이지`, function () { let userName, userContact group(`회원 연락처 조회`, function () { - const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken)) + const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken, "/users/contact")) if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) { throw new Error("회원 연락처 조회 과정에서 예외 발생") @@ -191,7 +195,7 @@ export default function (data) { requirement: requirement }) - const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken)) + const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken, "/reservations/pending")) const responseBody = parseIdToString(pendingReservationCreateRes) if (pendingReservationCreateRes.status !== 200) { @@ -229,7 +233,7 @@ export default function (data) { let isConfirmed = false while (trial < 2) { sleep(30) - const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken)) + const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken, "/orders/${reservationId}/confirm")) if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) { isConfirmed = true diff --git a/test-scripts/create-schedule-scripts.js b/test-scripts/create-schedule-scripts.js index 35eb6a39..a698d3fd 100644 --- a/test-scripts/create-schedule-scripts.js +++ b/test-scripts/create-schedule-scripts.js @@ -1,17 +1,17 @@ import http from 'k6/http'; -import {check, sleep} from 'k6'; +import {check} from 'k6'; import exec from 'k6/execution'; import {BASE_URL, getHeaders, login, parseIdToString} from "./common.js"; import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; -const TOTAL_ITERATIONS = 85212; +const TOTAL_ITERATIONS = 200000; export const options = { scenarios: { schedule_creation: { executor: 'shared-iterations', - vus: 263, + vus: 100, iterations: TOTAL_ITERATIONS, maxDuration: '30m', }, @@ -97,7 +97,7 @@ function createSchedule(storeId, accessToken, schedule) { time: schedule.time, themeId: schedule.themeId, }); - const params = getHeaders(accessToken) + const params = getHeaders(accessToken, "/admin/stores/${id}/schedules") const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params); const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201}); @@ -105,7 +105,6 @@ function createSchedule(storeId, accessToken, schedule) { if (!success) { console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`); } - sleep(5) return success; } -- 2.47.2 From 257fcb517de4ce7675d8f2d3861d1aa972fc5198 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 10:26:18 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20PaymentDetail=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=EB=B0=8F=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=ED=99=95=EC=9E=A5=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/business/domain/PaymentDetail.kt | 38 ++++++++ .../mapper/PaymentDetailMappingExtensions.kt | 91 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentDetail.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentDetailMappingExtensions.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentDetail.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentDetail.kt new file mode 100644 index 00000000..65193b04 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/domain/PaymentDetail.kt @@ -0,0 +1,38 @@ +package com.sangdol.roomescape.payment.business.domain + +abstract class PaymentDetail + +class BankTransferPaymentDetail( + val bankCode: BankCode, + val settlementStatus: String, +): PaymentDetail() + +class CardPaymentDetail( + val issuerCode: CardIssuerCode, + val number: String, + val amount: Int, + val cardType: CardType, + val ownerType: CardOwnerType, + val isInterestFree: Boolean, + val approveNo: String, + val installmentPlanMonths: Int +): PaymentDetail() + +class EasypayCardPaymentDetail( + val issuerCode: CardIssuerCode, + val number: String, + val amount: Int, + val cardType: CardType, + val ownerType: CardOwnerType, + val isInterestFree: Boolean, + val approveNo: String, + val installmentPlanMonths: Int, + val easypayProvider: EasyPayCompanyCode, + val easypayDiscountAmount: Int, +): PaymentDetail() + +class EasypayPrepaidPaymentDetail( + val provider: EasyPayCompanyCode, + val amount: Int, + val discountAmount: Int, +): PaymentDetail() diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentDetailMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentDetailMappingExtensions.kt new file mode 100644 index 00000000..a8ae3571 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentDetailMappingExtensions.kt @@ -0,0 +1,91 @@ +package com.sangdol.roomescape.payment.mapper + +import com.sangdol.roomescape.payment.business.domain.BankTransferPaymentDetail +import com.sangdol.roomescape.payment.business.domain.CardPaymentDetail +import com.sangdol.roomescape.payment.business.domain.EasypayCardPaymentDetail +import com.sangdol.roomescape.payment.business.domain.EasypayPrepaidPaymentDetail +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity + +fun BankTransferPaymentDetail.toEntity( + id: Long, + paymentId: Long, + suppliedAmount: Int, + vat: Int +): PaymentDetailEntity { + return PaymentBankTransferDetailEntity( + id = id, + paymentId = paymentId, + suppliedAmount = suppliedAmount, + vat = vat, + bankCode = this.bankCode, + settlementStatus = this.settlementStatus + ) +} + +fun CardPaymentDetail.toEntity( + id: Long, + paymentId: Long, + suppliedAmount: Int, + vat: Int +): PaymentDetailEntity { + return PaymentCardDetailEntity( + id = id, + paymentId = paymentId, + suppliedAmount = suppliedAmount, + vat = vat, + issuerCode = issuerCode, + cardType = cardType, + ownerType = ownerType, + amount = amount, + cardNumber = this.number, + approvalNumber = this.approveNo, + installmentPlanMonths = installmentPlanMonths, + isInterestFree = isInterestFree, + easypayProviderCode = null, + easypayDiscountAmount = null + ) +} + +fun EasypayCardPaymentDetail.toEntity( + id: Long, + paymentId: Long, + suppliedAmount: Int, + vat: Int +): PaymentDetailEntity { + return PaymentCardDetailEntity( + id = id, + paymentId = paymentId, + suppliedAmount = suppliedAmount, + vat = vat, + issuerCode = issuerCode, + cardType = cardType, + ownerType = ownerType, + amount = amount, + cardNumber = this.number, + approvalNumber = this.approveNo, + installmentPlanMonths = installmentPlanMonths, + isInterestFree = isInterestFree, + easypayProviderCode = this.easypayProvider, + easypayDiscountAmount = this.easypayDiscountAmount + ) +} + +fun EasypayPrepaidPaymentDetail.toEntity( + id: Long, + paymentId: Long, + suppliedAmount: Int, + vat: Int +): PaymentDetailEntity { + return PaymentEasypayPrepaidDetailEntity( + id = id, + paymentId = paymentId, + suppliedAmount = suppliedAmount, + vat = vat, + easypayProviderCode = this.provider, + amount = this.amount, + discountAmount = this.discountAmount + ) +} -- 2.47.2 From f4b9d1207e78f8707ac195e6bf3a9e1d27f8814f Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 10:26:34 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20PaymentEvent=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20=EB=B0=8F=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/business/event/PaymentEvent.kt | 22 ++++++++ .../mapper/PaymentEventMappingExtensions.kt | 53 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEvent.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentEventMappingExtensions.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEvent.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEvent.kt new file mode 100644 index 00000000..d8db4678 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEvent.kt @@ -0,0 +1,22 @@ +package com.sangdol.roomescape.payment.business.event + +import com.sangdol.roomescape.payment.business.domain.PaymentDetail +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentStatus +import com.sangdol.roomescape.payment.business.domain.PaymentType +import java.time.Instant + +class PaymentEvent( + val reservationId: Long, + val paymentKey: String, + val orderId: String, + val type: PaymentType, + val status: PaymentStatus, + val totalAmount: Int, + val vat: Int, + val suppliedAmount: Int, + val method: PaymentMethod, + val requestedAt: Instant, + val approvedAt: Instant, + val detail: PaymentDetail +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentEventMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentEventMappingExtensions.kt new file mode 100644 index 00000000..c25c8cc2 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentEventMappingExtensions.kt @@ -0,0 +1,53 @@ +package com.sangdol.roomescape.payment.mapper + +import com.sangdol.roomescape.payment.business.domain.* +import com.sangdol.roomescape.payment.business.event.PaymentEvent +import com.sangdol.roomescape.payment.exception.PaymentErrorCode +import com.sangdol.roomescape.payment.exception.PaymentException +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity + +fun PaymentEvent.toEntity(id: Long) = PaymentEntity( + id = id, + reservationId = this.reservationId, + paymentKey = this.paymentKey, + orderId = this.orderId, + totalAmount = this.totalAmount, + requestedAt = this.requestedAt, + approvedAt = this.approvedAt, + type = this.type, + method = this.method, + status = this.status +) + +fun PaymentEvent.toDetailEntity(id: Long, paymentId: Long): PaymentDetailEntity { + val suppliedAmount = this.suppliedAmount + val vat = this.vat + + return when (this.method) { + PaymentMethod.TRANSFER -> { + (this.detail as? BankTransferPaymentDetail) + ?.toEntity(id, paymentId, suppliedAmount, vat) + ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } + + PaymentMethod.EASY_PAY -> { + when (this.detail) { + is EasypayCardPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) } + is EasypayPrepaidPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) } + + else -> { + throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } + } + } + + PaymentMethod.CARD -> { + (this.detail as? CardPaymentDetail) + ?.toEntity(id, paymentId, suppliedAmount, vat) + ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } + + else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) + } +} -- 2.47.2 From d0ee55be955b4e0d6797c80320de6881b8b4b067 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 10:26:52 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20ReservationConfirmEvent=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/business/event/ReservationConfirmEvent.kt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationConfirmEvent.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationConfirmEvent.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationConfirmEvent.kt new file mode 100644 index 00000000..e3ea8135 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationConfirmEvent.kt @@ -0,0 +1,5 @@ +package com.sangdol.roomescape.reservation.business.event + +class ReservationConfirmEvent( + val reservationId: Long +) -- 2.47.2 From e0e7902654efec1e50ac775ee711e12796c0f6d1 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 13:15:52 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8A=94=20Pay?= =?UTF-8?q?mentEventListener=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/event/PaymentEventListener.kt | 48 +++++++++++++++++ .../event/PaymentEventListenerTest.kt | 53 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListener.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListenerTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListener.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListener.kt new file mode 100644 index 00000000..6ef93f0b --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListener.kt @@ -0,0 +1,48 @@ +package com.sangdol.roomescape.payment.business.event + +import com.sangdol.common.persistence.IDGenerator +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository +import com.sangdol.roomescape.payment.mapper.toDetailEntity +import com.sangdol.roomescape.payment.mapper.toEntity +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class PaymentEventListener( + private val idGenerator: IDGenerator, + private val paymentRepository: PaymentRepository, + private val paymentDetailRepository: PaymentDetailRepository +) { + + @Async + @EventListener + @Transactional + fun handlePaymentEvent(event: PaymentEvent) { + val reservationId = event.reservationId + + log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 수신: reservationId=${reservationId}, paymentKey=${event.paymentKey}" } + + val paymentId = idGenerator.create() + val paymentEntity: PaymentEntity = event.toEntity(paymentId) + paymentRepository.save(paymentEntity).also { + log.info { "[handlePaymentEvent] 결제 정보 저장 완료: paymentId=${paymentId}" } + } + + val paymentDetailId = idGenerator.create() + val paymentDetailEntity: PaymentDetailEntity = event.toDetailEntity(id = paymentDetailId, paymentId = paymentId) + paymentDetailRepository.save(paymentDetailEntity).also { + log.info { "[handlePaymentEvent] 결제 상세 저장 완료: paymentDetailId=${paymentDetailId}" } + } + + log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 처리 완료" } + } +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListenerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListenerTest.kt new file mode 100644 index 00000000..c8a987f9 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListenerTest.kt @@ -0,0 +1,53 @@ +package com.sangdol.roomescape.payment.business.event + +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository +import com.sangdol.roomescape.payment.mapper.toEvent +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.PaymentFixture +import com.sangdol.roomescape.supports.initialize +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe + +class PaymentEventListenerTest( + private val paymentEventListener: PaymentEventListener, + private val paymentRepository: PaymentRepository, + private val paymentDetailRepository: PaymentDetailRepository, +) : FunSpecSpringbootTest() { + + init { + test("결제 완료 이벤트를 처리한다.") { + val reservationId = initialize("FK 제약조건 해소를 위한 예약 생성") { + val user = testAuthUtil.defaultUser() + dummyInitializer.createPendingReservation(user) + }.id + + val paymentExternalAPIResponse = PaymentFixture.confirmResponse( + paymentKey = "paymentKey", + amount = 100_000, + method = PaymentMethod.CARD + ) + + paymentEventListener.handlePaymentEvent(paymentExternalAPIResponse.toEvent(reservationId)).also { + Thread.sleep(100) + } + + val payment = paymentRepository.findByReservationId(reservationId) + assertSoftly(payment!!) { + this.paymentKey shouldBe paymentExternalAPIResponse.paymentKey + this.totalAmount shouldBe paymentExternalAPIResponse.totalAmount + this.method shouldBe paymentExternalAPIResponse.method + } + + val paymentDetail = paymentDetailRepository.findByPaymentId(payment.id) + assertSoftly(paymentDetail) { + this.shouldNotBeNull() + this::class shouldBe PaymentCardDetailEntity::class + (this as PaymentCardDetailEntity).amount shouldBe paymentExternalAPIResponse.totalAmount + } + } + } +} -- 2.47.2 From dbd2b9fb0c15312c6e0b6af92bccc9e4d1c87538 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 13:16:44 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20PaymentClient=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B0=9B=EC=9D=80=20=EC=99=B8=EB=B6=80=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=ED=95=98=EB=8A=94=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=20=EC=BB=A4=EB=B0=8B=EC=9D=98=20PaymentEventListenerT?= =?UTF-8?q?est=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PaymentClientDTOMappingExtensions.kt | 87 +++++++++++++++++++ .../event => }/PaymentEventListenerTest.kt | 5 +- 2 files changed, 90 insertions(+), 2 deletions(-) rename service/src/test/kotlin/com/sangdol/roomescape/payment/{business/event => }/PaymentEventListenerTest.kt (95%) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt index cc2979a8..e5794fd0 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt @@ -1,5 +1,7 @@ package com.sangdol.roomescape.payment.mapper +import com.sangdol.roomescape.payment.business.domain.* +import com.sangdol.roomescape.payment.business.event.PaymentEvent import com.sangdol.roomescape.payment.dto.CancelDetail import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.exception.PaymentErrorCode @@ -94,3 +96,88 @@ fun CancelDetail.toEntity( transferDiscountAmount = this.transferDiscountAmount, easypayDiscountAmount = this.easyPayDiscountAmount ) + +fun PaymentGatewayResponse.toEvent(reservationId: Long): PaymentEvent { + return PaymentEvent( + reservationId = reservationId, + paymentKey = this.paymentKey, + orderId = this.orderId, + type = this.type, + status = this.status, + totalAmount = this.totalAmount, + vat = this.vat, + suppliedAmount = this.suppliedAmount, + method = this.method, + requestedAt = this.requestedAt.toInstant(), + approvedAt = this.approvedAt.toInstant(), + detail = this.toDetail() + ) +} + +fun PaymentGatewayResponse.toDetail(): PaymentDetail { + return when(this.method) { + PaymentMethod.TRANSFER -> this.toBankTransferDetail() + PaymentMethod.CARD -> this.toCardDetail() + PaymentMethod.EASY_PAY -> { + if (this.card != null) { + this.toEasypayCardDetail() + } else { + this.toEasypayPrepaidDetail() + } + } + + else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) + } +} + +private fun PaymentGatewayResponse.toBankTransferDetail(): BankTransferPaymentDetail { + val bankTransfer = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + + return BankTransferPaymentDetail( + bankCode = bankTransfer.bankCode, + settlementStatus = bankTransfer.settlementStatus + ) +} + +private fun PaymentGatewayResponse.toCardDetail(): CardPaymentDetail { + val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + + return CardPaymentDetail( + issuerCode = cardDetail.issuerCode, + number = cardDetail.number, + amount = cardDetail.amount, + cardType = cardDetail.cardType, + ownerType = cardDetail.ownerType, + isInterestFree = cardDetail.isInterestFree, + approveNo = cardDetail.approveNo, + installmentPlanMonths = cardDetail.installmentPlanMonths + ) +} + +private fun PaymentGatewayResponse.toEasypayCardDetail(): EasypayCardPaymentDetail { + val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + + return EasypayCardPaymentDetail( + issuerCode = cardDetail.issuerCode, + number = cardDetail.number, + amount = cardDetail.amount, + cardType = cardDetail.cardType, + ownerType = cardDetail.ownerType, + isInterestFree = cardDetail.isInterestFree, + approveNo = cardDetail.approveNo, + installmentPlanMonths = cardDetail.installmentPlanMonths, + easypayProvider = easypay.provider, + easypayDiscountAmount = easypay.discountAmount + ) +} + +private fun PaymentGatewayResponse.toEasypayPrepaidDetail(): EasypayPrepaidPaymentDetail { + val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + + return EasypayPrepaidPaymentDetail( + provider = easypay.provider, + amount = easypay.amount, + discountAmount = easypay.discountAmount + ) +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListenerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentEventListenerTest.kt similarity index 95% rename from service/src/test/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListenerTest.kt rename to service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentEventListenerTest.kt index c8a987f9..1808ea2d 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/business/event/PaymentEventListenerTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentEventListenerTest.kt @@ -1,6 +1,7 @@ -package com.sangdol.roomescape.payment.business.event +package com.sangdol.roomescape.payment import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.event.PaymentEventListener import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository @@ -50,4 +51,4 @@ class PaymentEventListenerTest( } } } -} +} \ No newline at end of file -- 2.47.2 From c1eb1aa2b4554a61a46c8bb45defb8df6302e640 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 13:36:27 +0900 Subject: [PATCH 10/17] =?UTF-8?q?refactor:=20PaymentService=20=EB=82=B4=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=ED=99=95=EC=A0=95=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=90=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/business/PaymentService.kt | 8 +- .../roomescape/payment/PaymentServiceTest.kt | 125 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index 99794588..0f5ff705 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -8,9 +8,11 @@ import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.persistence.* +import com.sangdol.roomescape.payment.mapper.toEvent import com.sangdol.roomescape.payment.mapper.toResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -24,12 +26,14 @@ class PaymentService( private val canceledPaymentRepository: CanceledPaymentRepository, private val paymentWriter: PaymentWriter, private val transactionExecutionUtil: TransactionExecutionUtil, + private val eventPublisher: ApplicationEventPublisher ) { - fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse { + fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse { log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" } try { return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also { - log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" } + eventPublisher.publishEvent(it.toEvent(reservationId)) + log.info { "[requestConfirm] 결제 및 이벤트 발행 완료: paymentKey=${request.paymentKey}" } } } catch (e: Exception) { when(e) { diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt new file mode 100644 index 00000000..c1a20307 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt @@ -0,0 +1,125 @@ +package com.sangdol.roomescape.payment + +import com.ninjasquad.springmockk.MockkBean +import com.sangdol.roomescape.payment.business.PaymentService +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode +import com.sangdol.roomescape.payment.business.event.PaymentEvent +import com.sangdol.roomescape.payment.business.event.PaymentEventListener +import com.sangdol.roomescape.payment.exception.ExternalPaymentException +import com.sangdol.roomescape.payment.exception.PaymentErrorCode +import com.sangdol.roomescape.payment.exception.PaymentException +import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.PaymentFixture +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import io.mockk.* +import org.junit.jupiter.api.assertThrows + +class PaymentServiceTest( + private val paymentService: PaymentService, + @MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener, + @MockkBean(relaxed = true) private val tosspayClient: TosspayClient +) : FunSpecSpringbootTest() { + + init { + afterTest { + clearAllMocks() + } + + context("결제를 승인한다.") { + val request = PaymentFixture.confirmRequest + + test("결제 정상 승인 및 이벤트 발행 확인") { + val tosspayAPIResponse = PaymentFixture.confirmResponse( + paymentKey = request.paymentKey, + amount = request.amount, + orderId = request.orderId, + method = PaymentMethod.CARD + ) + + val paymentEventSlot = slot() + + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } returns tosspayAPIResponse + + every { + paymentEventListener.handlePaymentEvent(capture(paymentEventSlot)) + } just runs + + paymentService.requestConfirm(12345L, request) + + assertSoftly(paymentEventSlot.captured) { + this.paymentKey shouldBe request.paymentKey + this.orderId shouldBe request.orderId + this.totalAmount shouldBe request.amount + this.method shouldBe PaymentMethod.CARD + } + } + + context("외부 API 요청 과정에서 예외가 발생하면, 예외를 PaymentException으로 변환한 뒤 던진다.") { + + test("외부 API가 4xx 응답을 보내면 ${PaymentErrorCode.PAYMENT_CLIENT_ERROR}로 변환하여 예외를 던진다.") { + val expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR + val exception = ExternalPaymentException(400, "INVALID_REQUEST", "잘못된 요청입니다.") + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } throws exception + + assertThrows { + paymentService.requestConfirm(12345L, request) + }.also { + it.errorCode shouldBe expectedErrorCode + it.message shouldBe expectedErrorCode.message + } + } + + test("외부 API가 5xx 응답을 보내면 ${PaymentErrorCode.PAYMENT_PROVIDER_ERROR}로 변환하여 예외를 던진다.") { + val expectedErrorCode = PaymentErrorCode.PAYMENT_PROVIDER_ERROR + val exception = ExternalPaymentException(500, "UNKNOWN_PAYMENT_ERROR", "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요.") + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } throws exception + + assertThrows { + paymentService.requestConfirm(12345L, request) + }.also { + it.errorCode shouldBe expectedErrorCode + it.message shouldBe expectedErrorCode.message + } + } + + test("외부 API의 에러코드가 ${UserFacingPaymentErrorCode::class.simpleName}에 있으면 해당 예외 메시지를 담아 던진다.") { + val expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR + val exception = ExternalPaymentException(400, "EXCEED_MAX_CARD_INSTALLMENT_PLAN", "설정 가능한 최대 할부 개월 수를 초과했습니다.") + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } throws exception + + assertThrows { + paymentService.requestConfirm(12345L, request) + }.also { + it.errorCode shouldBe expectedErrorCode + it.message shouldBe "${expectedErrorCode.message}(${exception.message})" + } + } + + test("외부 API에서 예상치 못한 예외가 발생한 경우 ${PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR}로 변환한다.") { + val expectedErrorCode = PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } throws Exception("unexpected") + + assertThrows { + paymentService.requestConfirm(12345L, request) + }.also { + it.errorCode shouldBe expectedErrorCode + it.message shouldBe expectedErrorCode.message + } + } + } + } + } +} -- 2.47.2 From c3330e5652df6dddcf8fb68ba0f1c35683926615 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 13:55:13 +0900 Subject: [PATCH 11/17] =?UTF-8?q?refactor:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=ED=99=94?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존의 결제 확정 API 및 테스트 제거(취소는 유지) - PaymentWriter 제거 - 테스트 코드 반영 --- .../payment/business/PaymentService.kt | 34 +-- .../payment/business/PaymentWriter.kt | 80 ----- .../roomescape/payment/docs/PaymentAPI.kt | 10 - .../PaymentClientDTOMappingExtensions.kt | 74 +---- .../payment/web/PaymentController.kt | 17 +- .../roomescape/payment/PaymentAPITest.kt | 275 ++---------------- .../roomescape/supports/DummyInitializer.kt | 55 ++-- .../roomescape/supports/KotestConfig.kt | 33 ++- 8 files changed, 73 insertions(+), 505 deletions(-) delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt index 0f5ff705..3998f46e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentService.kt @@ -1,5 +1,6 @@ package com.sangdol.roomescape.payment.business +import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode import com.sangdol.roomescape.payment.dto.* @@ -8,6 +9,7 @@ import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.persistence.* +import com.sangdol.roomescape.payment.mapper.toEntity import com.sangdol.roomescape.payment.mapper.toEvent import com.sangdol.roomescape.payment.mapper.toResponse import io.github.oshai.kotlinlogging.KLogger @@ -20,11 +22,11 @@ private val log: KLogger = KotlinLogging.logger {} @Service class PaymentService( + private val idGenerator: IDGenerator, private val paymentClient: TosspayClient, private val paymentRepository: PaymentRepository, private val paymentDetailRepository: PaymentDetailRepository, private val canceledPaymentRepository: CanceledPaymentRepository, - private val paymentWriter: PaymentWriter, private val transactionExecutionUtil: TransactionExecutionUtil, private val eventPublisher: ApplicationEventPublisher ) { @@ -60,19 +62,6 @@ class PaymentService( } } - fun savePayment( - reservationId: Long, - paymentGatewayResponse: PaymentGatewayResponse - ): PaymentCreateResponse { - val payment: PaymentEntity = paymentWriter.createPayment( - reservationId = reservationId, - paymentGatewayResponse = paymentGatewayResponse - ) - val detail: PaymentDetailEntity = paymentWriter.createDetail(paymentGatewayResponse, payment.id) - - return PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) - } - fun cancel(userId: Long, request: PaymentCancelRequest) { val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId) @@ -83,12 +72,17 @@ class PaymentService( ) transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - paymentWriter.cancel( - userId = userId, - payment = payment, - requestedAt = request.requestedAt, - cancelResponse = clientCancelResponse - ) + val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() } + + clientCancelResponse.cancels.toEntity( + id = idGenerator.create(), + paymentId = payment.id, + cancelRequestedAt = request.requestedAt, + canceledBy = userId + ).also { + canceledPaymentRepository.save(it) + log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" } + } }.also { log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt deleted file mode 100644 index 948e9b4b..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/business/PaymentWriter.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.sangdol.roomescape.payment.business - -import com.sangdol.common.persistence.IDGenerator -import com.sangdol.roomescape.payment.exception.PaymentErrorCode -import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.business.domain.PaymentMethod -import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse -import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse -import com.sangdol.roomescape.payment.mapper.toCardDetailEntity -import com.sangdol.roomescape.payment.mapper.toEasypayPrepaidDetailEntity -import com.sangdol.roomescape.payment.mapper.toEntity -import com.sangdol.roomescape.payment.mapper.toTransferDetailEntity -import com.sangdol.roomescape.payment.infrastructure.persistence.* -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Component -import java.time.Instant - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class PaymentWriter( - private val paymentRepository: PaymentRepository, - private val paymentDetailRepository: PaymentDetailRepository, - private val canceledPaymentRepository: CanceledPaymentRepository, - private val idGenerator: IDGenerator, -) { - - fun createPayment( - reservationId: Long, - paymentGatewayResponse: PaymentGatewayResponse - ): PaymentEntity { - log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentGatewayResponse.paymentKey}" } - - return paymentGatewayResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also { - paymentRepository.save(it) - log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" } - } - } - - fun createDetail( - paymentGatewayResponse: PaymentGatewayResponse, - paymentId: Long, - ): PaymentDetailEntity { - val method: PaymentMethod = paymentGatewayResponse.method - val id = idGenerator.create() - - if (method == PaymentMethod.TRANSFER) { - return paymentDetailRepository.save(paymentGatewayResponse.toTransferDetailEntity(id, paymentId)) - } - if (method == PaymentMethod.EASY_PAY && paymentGatewayResponse.card == null) { - return paymentDetailRepository.save(paymentGatewayResponse.toEasypayPrepaidDetailEntity(id, paymentId)) - } - if (paymentGatewayResponse.card != null) { - return paymentDetailRepository.save(paymentGatewayResponse.toCardDetailEntity(id, paymentId)) - } - throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) - } - - fun cancel( - userId: Long, - payment: PaymentEntity, - requestedAt: Instant, - cancelResponse: PaymentGatewayCancelResponse - ): CanceledPaymentEntity { - log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" } - - paymentRepository.save(payment.apply { this.cancel() }) - - return cancelResponse.cancels.toEntity( - id = idGenerator.create(), - paymentId = payment.id, - cancelRequestedAt = requestedAt, - canceledBy = userId - ).also { - canceledPaymentRepository.save(it) - log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" } - } - } -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt index 5f7160b8..682e35e2 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/docs/PaymentAPI.kt @@ -2,11 +2,8 @@ package com.sangdol.roomescape.payment.docs import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.roomescape.auth.web.support.User -import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.common.types.CurrentUserContext -import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.dto.PaymentCancelRequest -import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses @@ -16,13 +13,6 @@ import org.springframework.web.bind.annotation.RequestBody interface PaymentAPI { - @UserOnly - @Operation(summary = "결제 승인") - @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) - fun confirmPayment( - @Valid @RequestBody request: PaymentConfirmRequest - ): ResponseEntity> - @Operation(summary = "결제 취소") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) fun cancelPayment( diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt index e5794fd0..e88c9da4 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/mapper/PaymentClientDTOMappingExtensions.kt @@ -6,79 +6,9 @@ import com.sangdol.roomescape.payment.dto.CancelDetail import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.persistence.* +import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import java.time.Instant -fun PaymentGatewayResponse.toEntity( - id: Long, - reservationId: Long, -) = PaymentEntity( - id = id, - reservationId = reservationId, - paymentKey = this.paymentKey, - orderId = this.orderId, - totalAmount = this.totalAmount, - requestedAt = this.requestedAt.toInstant(), - approvedAt = this.approvedAt.toInstant(), - type = this.type, - method = this.method, - status = this.status, -) - -fun PaymentGatewayResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity { - val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) - - return PaymentCardDetailEntity( - id = id, - paymentId = paymentId, - suppliedAmount = this.suppliedAmount, - vat = this.vat, - issuerCode = cardDetail.issuerCode, - cardType = cardDetail.cardType, - ownerType = cardDetail.ownerType, - amount = cardDetail.amount, - cardNumber = cardDetail.number, - approvalNumber = cardDetail.approveNo, - installmentPlanMonths = cardDetail.installmentPlanMonths, - isInterestFree = cardDetail.isInterestFree, - easypayProviderCode = this.easyPay?.provider, - easypayDiscountAmount = this.easyPay?.discountAmount, - ) -} - -fun PaymentGatewayResponse.toEasypayPrepaidDetailEntity( - id: Long, - paymentId: Long -): PaymentEasypayPrepaidDetailEntity { - val easyPayDetail = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) - - return PaymentEasypayPrepaidDetailEntity( - id = id, - paymentId = paymentId, - suppliedAmount = this.suppliedAmount, - vat = this.vat, - easypayProviderCode = easyPayDetail.provider, - amount = easyPayDetail.amount, - discountAmount = easyPayDetail.discountAmount - ) -} - -fun PaymentGatewayResponse.toTransferDetailEntity( - id: Long, - paymentId: Long -): PaymentBankTransferDetailEntity { - val transferDetail = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) - - return PaymentBankTransferDetailEntity( - id = id, - paymentId = paymentId, - suppliedAmount = this.suppliedAmount, - vat = this.vat, - bankCode = transferDetail.bankCode, - settlementStatus = transferDetail.settlementStatus - ) -} - fun CancelDetail.toEntity( id: Long, paymentId: Long, @@ -115,7 +45,7 @@ fun PaymentGatewayResponse.toEvent(reservationId: Long): PaymentEvent { } fun PaymentGatewayResponse.toDetail(): PaymentDetail { - return when(this.method) { + return when (this.method) { PaymentMethod.TRANSFER -> this.toBankTransferDetail() PaymentMethod.CARD -> this.toCardDetail() PaymentMethod.EASY_PAY -> { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt index 9f2f36b2..0eae2eee 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/web/PaymentController.kt @@ -6,27 +6,18 @@ import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.docs.PaymentAPI import com.sangdol.roomescape.payment.dto.PaymentCancelRequest -import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest -import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* +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.RestController @RestController @RequestMapping("/payments") class PaymentController( private val paymentService: PaymentService ) : PaymentAPI { - - @PostMapping("/confirm") - override fun confirmPayment( - @Valid @RequestBody request: PaymentConfirmRequest - ): ResponseEntity> { - val response = paymentService.requestConfirm(request) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - @PostMapping("/cancel") override fun cancelPayment( @User user: CurrentUserContext, diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt index 773912ea..cdb12b63 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentAPITest.kt @@ -4,22 +4,21 @@ import com.ninjasquad.springmockk.MockkBean import com.sangdol.common.types.web.HttpStatus import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.payment.business.PaymentService -import com.sangdol.roomescape.payment.business.domain.* -import com.sangdol.roomescape.payment.dto.* -import com.sangdol.roomescape.payment.exception.ExternalPaymentException +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.domain.PaymentStatus +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository -import com.sangdol.roomescape.supports.FunSpecSpringbootTest -import com.sangdol.roomescape.supports.PaymentFixture -import com.sangdol.roomescape.supports.runExceptionTest -import com.sangdol.roomescape.supports.runTest +import com.sangdol.roomescape.payment.mapper.toDetailEntity +import com.sangdol.roomescape.payment.mapper.toEntity +import com.sangdol.roomescape.payment.mapper.toEvent +import com.sangdol.roomescape.supports.* import io.kotest.matchers.shouldBe -import io.mockk.clearMocks import io.mockk.every -import org.hamcrest.CoreMatchers.containsString -import org.hamcrest.CoreMatchers.equalTo import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod @@ -28,211 +27,10 @@ class PaymentAPITest( private val tosspayClient: TosspayClient, private val paymentService: PaymentService, private val paymentRepository: PaymentRepository, + private val paymentDetailRepository: PaymentDetailRepository, private val canceledPaymentRepository: CanceledPaymentRepository ) : FunSpecSpringbootTest() { init { - context("결제를 승인한다.") { - context("권한이 없으면 접근할 수 없다.") { - val endpoint = "/payments/confirm" - - test("비회원") { - runExceptionTest( - method = HttpMethod.POST, - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND - ) - } - - test("관리자") { - runExceptionTest( - token = testAuthUtil.defaultHqAdminLogin().second, - method = HttpMethod.POST, - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.ACCESS_DENIED - ) - } - } - - val amount = 100_000 - context("간편결제 + 카드로 ${amount}원을 결제한다.") { - context("일시불") { - test("토스페이 + 토스뱅크카드(신용)") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = amount, - issuerCode = CardIssuerCode.TOSS_BANK, - cardType = CardType.CREDIT, - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.TOSSPAY - ) - ) - } - - test("삼성페이 + 삼성카드(법인)") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = amount, - issuerCode = CardIssuerCode.SAMSUNG, - cardType = CardType.CREDIT, - ownerType = CardOwnerType.CORPORATE - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.SAMSUNGPAY - ) - ) - } - } - - context("할부") { - val installmentPlanMonths = 12 - test("네이버페이 + 신한카드 / 12개월") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = amount, - issuerCode = CardIssuerCode.SHINHAN, - installmentPlanMonths = installmentPlanMonths - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.NAVERPAY - ) - ) - } - } - - context("간편결제사 포인트 일부 사용") { - val point = (amount * 0.1).toInt() - test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") { - runConfirmTest( - amount = amount, - cardDetail = PaymentFixture.cardDetail( - amount = (amount - point), - issuerCode = CardIssuerCode.KOOKMIN, - cardType = CardType.CHECK - ), - easyPayDetail = PaymentFixture.easypayDetail( - amount = 0, - provider = EasyPayCompanyCode.TOSSPAY, - discountAmount = point - ) - ) - } - } - } - - context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") { - test("토스페이 + 토스페이머니 / 전액") { - runConfirmTest( - easyPayDetail = PaymentFixture.easypayDetail( - amount = amount, - provider = EasyPayCompanyCode.TOSSPAY - ) - ) - } - - val point = (amount * 0.05).toInt() - - test("카카오페이 + 카카오페이머니 / $point 사용") { - runConfirmTest( - easyPayDetail = PaymentFixture.easypayDetail( - amount = (amount - point), - provider = EasyPayCompanyCode.KAKAOPAY, - discountAmount = point - ) - ) - } - } - - context("계좌이체로 결제한다.") { - test("토스뱅크") { - runConfirmTest( - transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK) - ) - } - } - - context("결제 처리중 오류가 발생한다.") { - lateinit var token: String - val commonRequest = PaymentFixture.confirmRequest - - beforeTest { - token = testAuthUtil.defaultUserLogin().second - } - - afterTest { - clearMocks(tosspayClient) - } - - test("예외 코드가 UserFacingPaymentErrorCode에 있으면 결제 실패 메시지를 같이 담는다.") { - val statusCode = HttpStatus.BAD_REQUEST.value() - val message = "거래금액 한도를 초과했습니다." - - every { - tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount) - } throws ExternalPaymentException( - httpStatusCode = statusCode, - errorCode = UserFacingPaymentErrorCode.EXCEED_MAX_AMOUNT.name, - message = message - ) - - runTest( - token = token, - using = { - body(commonRequest) - }, - on = { - post("/payments/confirm") - }, - expect = { - statusCode(statusCode) - body("code", equalTo(PaymentErrorCode.PAYMENT_CLIENT_ERROR.errorCode)) - body("message", containsString(message)) - } - ) - } - - context("예외 코드가 UserFacingPaymentErrorCode에 없으면 Client의 상태 코드에 따라 다르게 처리한다.") { - mapOf( - HttpStatus.BAD_REQUEST.value() to PaymentErrorCode.PAYMENT_CLIENT_ERROR, - HttpStatus.INTERNAL_SERVER_ERROR.value() to PaymentErrorCode.PAYMENT_PROVIDER_ERROR - ).forEach { (statusCode, expectedErrorCode) -> - test("statusCode=${statusCode}") { - val message = "잘못된 시크릿키 연동 정보 입니다." - - every { - tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount) - } throws ExternalPaymentException( - httpStatusCode = statusCode, - errorCode = "INVALID_API_KEY", - message = message - ) - - runTest( - token = token, - using = { - body(commonRequest) - }, - on = { - post("/payments/confirm") - }, - expect = { - statusCode(statusCode) - body("code", equalTo(expectedErrorCode.errorCode)) - body("message", equalTo(expectedErrorCode.message)) - } - ) - } - } - } - } - } - context("결제를 취소한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/payments/cancel" @@ -262,7 +60,7 @@ class PaymentAPITest( val reservation = dummyInitializer.createConfirmReservation(user = user) val confirmRequest = PaymentFixture.confirmRequest - val paymentCreateResponse = createPayment( + val paymentEntity = createPayment( request = confirmRequest, reservationId = reservation.id ) @@ -289,10 +87,10 @@ class PaymentAPITest( statusCode(HttpStatus.OK.value()) } ).also { - val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) + val payment = paymentRepository.findByIdOrNull(paymentEntity.id) ?: throw AssertionError("Unexpected Exception Occurred.") val canceledPayment = - canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) + canceledPaymentRepository.findByPaymentId(paymentEntity.id) ?: throw AssertionError("Unexpected Exception Occurred.") payment.status shouldBe PaymentStatus.CANCELED @@ -319,7 +117,7 @@ class PaymentAPITest( private fun createPayment( request: PaymentConfirmRequest, reservationId: Long, - ): PaymentCreateResponse { + ): PaymentEntity { every { tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) } returns PaymentFixture.confirmResponse( @@ -331,49 +129,10 @@ class PaymentAPITest( transferDetail = null, ) - val paymentResponse = paymentService.requestConfirm(request) - return paymentService.savePayment(reservationId, paymentResponse) - } + val paymentEvent = paymentService.requestConfirm(reservationId, request).toEvent(reservationId) - fun runConfirmTest( - cardDetail: CardDetailResponse? = null, - easyPayDetail: EasyPayDetailResponse? = null, - transferDetail: TransferDetailResponse? = null, - paymentKey: String = "paymentKey", - amount: Int = 10000, - ) { - val token = testAuthUtil.defaultUserLogin().second - val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) - - val method = if (easyPayDetail != null) { - PaymentMethod.EASY_PAY - } else if (cardDetail != null) { - PaymentMethod.CARD - } else if (transferDetail != null) { - PaymentMethod.TRANSFER - } else { - throw AssertionError("결제타입 확인 필요.") + return paymentRepository.save(paymentEvent.toEntity(IDGenerator.create())).also { + paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), it.id)) } - - val clientResponse = PaymentFixture.confirmResponse( - paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail - ) - - every { - tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) - } returns clientResponse - - runTest( - token = token, - using = { - body(request) - }, - on = { - post("/payments/confirm") - }, - expect = { - statusCode(HttpStatus.OK.value()) - } - ) } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index dca0fefe..324d9f29 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -1,11 +1,11 @@ package com.sangdol.roomescape.supports -import com.sangdol.roomescape.payment.business.PaymentWriter import com.sangdol.roomescape.payment.business.domain.PaymentMethod import com.sangdol.roomescape.payment.dto.* -import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.* +import com.sangdol.roomescape.payment.mapper.toDetailEntity +import com.sangdol.roomescape.payment.mapper.toEntity +import com.sangdol.roomescape.payment.mapper.toEvent import com.sangdol.roomescape.payment.mapper.toResponse import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity @@ -33,7 +33,8 @@ class DummyInitializer( private val scheduleRepository: ScheduleRepository, private val reservationRepository: ReservationRepository, private val paymentRepository: PaymentRepository, - private val paymentWriter: PaymentWriter + private val paymentDetailRepository: PaymentDetailRepository, + private val canceledPaymentRepository: CanceledPaymentRepository ) { fun createStore( @@ -156,27 +157,6 @@ class DummyInitializer( } } - fun createExpiredOrCanceledReservation( - user: UserEntity, - status: ReservationStatus, - storeId: Long = IDGenerator.create(), - themeRequest: ThemeCreateRequest = ThemeFixture.createRequest, - scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, - reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, - ): ReservationEntity { - return createPendingReservation(user, storeId, themeRequest, scheduleRequest, reservationRequest).apply { - this.status = status - }.also { - reservationRepository.save(it) - - scheduleRepository.findByIdOrNull(it.scheduleId)?.let { schedule -> - schedule.status = ScheduleStatus.AVAILABLE - schedule.holdExpiredAt = null - scheduleRepository.save(schedule) - } - } - } - fun createPayment( reservationId: Long, request: PaymentConfirmRequest = PaymentFixture.confirmRequest, @@ -204,12 +184,10 @@ class DummyInitializer( transferDetail = transferDetail ) - val payment = paymentWriter.createPayment( - reservationId = reservationId, - paymentGatewayResponse = clientConfirmResponse - ) + val paymentEvent = clientConfirmResponse.toEvent(reservationId) + val payment = paymentRepository.save(paymentEvent.toEntity(IDGenerator.create())) - val detail = paymentWriter.createDetail(clientConfirmResponse, payment.id) + val detail = paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), payment.id)) return payment.toResponse(detail = detail.toResponse(), cancel = null) } @@ -227,11 +205,14 @@ class DummyInitializer( cancelReason = cancelReason, ) - return paymentWriter.cancel( - userId, - payment, - requestedAt = Instant.now(), - clientCancelResponse - ) + + return clientCancelResponse.cancels.toEntity( + id = IDGenerator.create(), + paymentId = payment.id, + cancelRequestedAt = Instant.now(), + canceledBy = userId + ).also { + canceledPaymentRepository.save(it) + } } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/KotestConfig.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/KotestConfig.kt index a9eae9bf..0dcf3b61 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/KotestConfig.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/KotestConfig.kt @@ -1,7 +1,8 @@ package com.sangdol.roomescape.supports import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository -import com.sangdol.roomescape.payment.business.PaymentWriter +import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository +import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository @@ -43,13 +44,7 @@ abstract class FunSpecSpringbootTest( } }) { @Autowired - private lateinit var userRepository: UserRepository - - @Autowired - private lateinit var adminRepository: AdminRepository - - @Autowired - private lateinit var storeRepository: StoreRepository + lateinit var testAuthUtil: TestAuthUtil @Autowired lateinit var dummyInitializer: DummyInitializer @@ -57,32 +52,40 @@ abstract class FunSpecSpringbootTest( @LocalServerPort var port: Int = 0 - lateinit var testAuthUtil: TestAuthUtil - override suspend fun beforeSpec(spec: Spec) { RestAssured.port = port - testAuthUtil = TestAuthUtil(userRepository, adminRepository, storeRepository) } } @TestConfiguration class TestConfig { + @Bean + fun testAuthUtil( + userRepository: UserRepository, + adminRepository: AdminRepository, + storeRepository: StoreRepository + ): TestAuthUtil { + return TestAuthUtil(userRepository, adminRepository, storeRepository) + } + @Bean fun dummyInitializer( storeRepository: StoreRepository, themeRepository: ThemeRepository, scheduleRepository: ScheduleRepository, reservationRepository: ReservationRepository, - paymentWriter: PaymentWriter, - paymentRepository: PaymentRepository + paymentRepository: PaymentRepository, + paymentDetailRepository: PaymentDetailRepository, + canceledPaymentRepository: CanceledPaymentRepository ): DummyInitializer { return DummyInitializer( themeRepository = themeRepository, scheduleRepository = scheduleRepository, reservationRepository = reservationRepository, - paymentWriter = paymentWriter, paymentRepository = paymentRepository, - storeRepository = storeRepository + storeRepository = storeRepository, + paymentDetailRepository = paymentDetailRepository, + canceledPaymentRepository = canceledPaymentRepository ) } } -- 2.47.2 From cce59e522ea1ec78710fa70c5c8f46460b7024e1 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 14:00:28 +0900 Subject: [PATCH 12/17] =?UTF-8?q?refactor:=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EB=8A=94=20Repository=20=EB=B3=84=EB=8F=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/ReservationRepository.kt | 7 +-- .../persistence/ScheduleRepository.kt | 15 ------ .../roomescape/test/TestSetupController.kt | 5 ++ .../sangdol/roomescape/test/TestSetupDTO.kt | 12 ++++- .../roomescape/test/TestSetupRepositories.kt | 50 +++++++++++++++++++ .../roomescape/test/TestSetupService.kt | 14 ++++-- .../persistence/UserRepositories.kt | 9 ---- 7 files changed, 80 insertions(+), 32 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupRepositories.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index fc56aa64..a61aae44 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -16,8 +16,8 @@ interface ReservationRepository : JpaRepository { @Query("SELECT r FROM ReservationEntity r WHERE r._id = :id") fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity? - - @Query(""" + @Query( + """ SELECT r.id FROM @@ -27,7 +27,8 @@ interface ReservationRepository : JpaRepository { WHERE r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) FOR UPDATE SKIP LOCKED - """, nativeQuery = true) + """, nativeQuery = true + ) fun findAllExpiredReservation(): List @Modifying diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt index 15850467..ca3b8ac9 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt @@ -1,7 +1,6 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview -import com.sangdol.roomescape.test.ScheduleWithThemeId import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock @@ -160,18 +159,4 @@ interface ScheduleRepository : JpaRepository { """ ) fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List): Int - - /** - * for test - */ - @Query(""" - SELECT - s.id, s.theme_id - FROM - schedule s - WHERE - s.status = 'AVAILABLE' - AND s.date > CURRENT_DATE - """, nativeQuery = true) - fun findAllAvailableSchedules(): List } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt index a514258c..36756d44 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt @@ -42,4 +42,9 @@ class TestSetupController( fun findAllStoreIds(): StoreIdList { return testSetupService.findAllStores() } + + @GetMapping("/reservations-with-user") + fun findAllReservationsWithUser(): ReservationWithUserList { + return testSetupService.findAllReservationWithUser() + } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt index 9baab4a5..84a6b98c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt @@ -44,4 +44,14 @@ data class StoreIdList( data class StoreId( val storeId: Long -) \ No newline at end of file +) + +data class ReservationWithUser( + val account: String, + val password: String, + val reservationId: Long +) + +data class ReservationWithUserList( + val results: List +) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupRepositories.kt b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupRepositories.kt new file mode 100644 index 00000000..100e7d6e --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupRepositories.kt @@ -0,0 +1,50 @@ +package com.sangdol.roomescape.test + +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity +import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface TestSetupUserRepository: JpaRepository { + /** + * for test + */ + @Query(""" + SELECT * FROM users u LIMIT :count + """, nativeQuery = true) + fun findUsersByCount(count: Long): List +} + +interface TestSetupScheduleRepository: JpaRepository { + /** + * for test + */ + @Query(""" + SELECT + s.id, s.theme_id + FROM + schedule s + WHERE + s.status = 'AVAILABLE' + AND s.date > CURRENT_DATE + """, nativeQuery = true) + fun findAllAvailableSchedules(): List + +} + +interface TestSetupReservationRepository: JpaRepository { + /** + * for test + */ + @Query( + """ + SELECT + u.email, u.password, r.id + FROM + reservation r + JOIN users u ON u.id = r.user_id + """, nativeQuery = true + ) + fun findAllReservationWithUser(): List +} \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt index 7063385b..8090bcc5 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt @@ -3,10 +3,8 @@ package com.sangdol.roomescape.test import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository -import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalTime @@ -16,8 +14,9 @@ class TestSetupService( private val themeRepository: ThemeRepository, private val storeRepository: StoreRepository, private val adminRepository: AdminRepository, - private val userRepository: UserRepository, - private val scheduleRepository: ScheduleRepository, + private val userRepository: TestSetupUserRepository, + private val scheduleRepository: TestSetupScheduleRepository, + private val reservationRepository: TestSetupReservationRepository ) { @Transactional(readOnly = true) @@ -85,4 +84,11 @@ class TestSetupService( StoreId(it.id) }) } + + @Transactional(readOnly = true) + fun findAllReservationWithUser(): ReservationWithUserList { + return ReservationWithUserList( + reservationRepository.findAllReservationWithUser() + ) + } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt index 8a334358..f10272a9 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt @@ -1,21 +1,12 @@ package com.sangdol.roomescape.user.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query interface UserRepository : JpaRepository { fun existsByEmail(email: String): Boolean fun existsByPhone(phone: String): Boolean fun findByEmail(email: String): UserEntity? - - /** - * for test - */ - @Query(""" - SELECT * FROM users u LIMIT :count - """, nativeQuery = true) - fun findUsersByCount(count: Long): List } interface UserStatusHistoryRepository : JpaRepository -- 2.47.2 From 66bf68826bdadf70dec9e67c0a47331c25ed729c Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 14:19:22 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20EventListener=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/ReservationEventListener.kt | 34 ++++++++++++++ .../persistence/ReservationRepository.kt | 19 ++++++++ .../event/ReservationEventListenerTest.kt | 45 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListener.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListenerTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListener.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListener.kt new file mode 100644 index 00000000..60dad491 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListener.kt @@ -0,0 +1,34 @@ +package com.sangdol.roomescape.reservation.business.event + +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ReservationEventListener( + private val reservationRepository: ReservationRepository +) { + + @Async + @EventListener + @Transactional + fun handleReservationConfirmEvent(event: ReservationConfirmEvent) { + val reservationId = event.reservationId + + log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 수신: reservationId=${reservationId}" } + val modifiedRows = reservationRepository.confirmReservation(Instant.now(), reservationId) + + if (modifiedRows == 0) { + log.warn { "[handleReservationConfirmEvent] 예상치 못한 예약 확정 실패 - 변경된 row 없음: reservationId=${reservationId}" } + } + + log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 처리 완료" } + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index a61aae44..bd812048 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -48,4 +48,23 @@ interface ReservationRepository : JpaRepository { """, nativeQuery = true ) fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List): Int + + @Modifying + @Query( + """ + UPDATE + reservation r + JOIN + schedule s ON r.schedule_id = s.id AND s.status = 'HOLD' + SET + r.status = 'CONFIRMED', + r.updated_at = :now, + s.status = 'RESERVED', + s.hold_expired_at = NULL + WHERE + r.id = :id + AND r.status = 'PAYMENT_IN_PROGRESS' + """, nativeQuery = true + ) + fun confirmReservation(@Param("now") now: Instant, @Param("id") id: Long): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListenerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListenerTest.kt new file mode 100644 index 00000000..0e472a63 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/event/ReservationEventListenerTest.kt @@ -0,0 +1,45 @@ +package com.sangdol.roomescape.reservation.business.event + +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.springframework.data.repository.findByIdOrNull + +class ReservationEventListenerTest( + private val reservationEventListener: ReservationEventListener, + private val reservationRepository: ReservationRepository, + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + + init { + test("예약 확정 이벤트를 처리한다.") { + val pendingReservation = dummyInitializer.createPendingReservation(testAuthUtil.defaultUser()).also { + it.status = ReservationStatus.PAYMENT_IN_PROGRESS + reservationRepository.saveAndFlush(it) + } + + val reservationConfirmEvent = ReservationConfirmEvent(pendingReservation.id) + + reservationEventListener.handleReservationConfirmEvent(reservationConfirmEvent).also { + Thread.sleep(100) + } + + assertSoftly(reservationRepository.findByIdOrNull(pendingReservation.id)) { + this.shouldNotBeNull() + this.status shouldBe ReservationStatus.CONFIRMED + } + + assertSoftly(scheduleRepository.findByIdOrNull(pendingReservation.scheduleId)) { + this.shouldNotBeNull() + this.status shouldBe ScheduleStatus.RESERVED + this.holdExpiredAt shouldBe null + } + } + } +} -- 2.47.2 From a83352c73342acee40426e0770320ea75e33db57 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 15:28:56 +0900 Subject: [PATCH 14/17] =?UTF-8?q?refactor:=20OrderService=EC=9D=98=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20&=20=EC=98=88=EC=95=BD=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/order/business/OrderService.kt | 101 +++--------------- .../order/business/OrderValidator.kt | 3 +- .../order/exception/OrderErrorCode.kt | 4 +- .../order/exception/OrderException.kt | 7 -- 4 files changed, 15 insertions(+), 100 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt index 76a9dcdc..e77703f6 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt @@ -1,146 +1,69 @@ package com.sangdol.roomescape.order.business -import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.RoomescapeException import com.sangdol.roomescape.order.exception.OrderErrorCode import com.sangdol.roomescape.order.exception.OrderException -import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult -import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity -import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest -import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse -import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.reservation.business.ReservationService +import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent import com.sangdol.roomescape.reservation.dto.ReservationStateResponse import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service private val log: KLogger = KotlinLogging.logger {} @Service class OrderService( - private val idGenerator: IDGenerator, private val reservationService: ReservationService, private val scheduleService: ScheduleService, private val paymentService: PaymentService, private val transactionExecutionUtil: TransactionExecutionUtil, private val orderValidator: OrderValidator, - private val paymentAttemptRepository: PaymentAttemptRepository, - private val orderPostProcessorService: OrderPostProcessorService + private val eventPublisher: ApplicationEventPublisher ) { fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) { - var trial: Long = 0 val paymentKey = paymentConfirmRequest.paymentKey log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } try { - trial = transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - getTrialAfterValidateCanConfirm(reservationId).also { - reservationService.markInProgress(reservationId) - } - } ?: run { - log.warn { "[confirm] 모든 paymentAttempts 조회 과정에서의 예상치 못한 null 응답: reservationId=${reservationId}" } - throw OrderException(OrderErrorCode.BOOKING_UNEXPECTED_ERROR) + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + validateCanConfirm(reservationId) + reservationService.markInProgress(reservationId) } - val paymentClientResponse: PaymentGatewayResponse = - requestConfirmPayment(reservationId, paymentConfirmRequest) + paymentService.requestConfirm(reservationId, paymentConfirmRequest) + eventPublisher.publishEvent(ReservationConfirmEvent(reservationId)) - orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse) + log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료" } } catch (e: Exception) { val errorCode: ErrorCode = if (e is RoomescapeException) { e.errorCode } else { - OrderErrorCode.BOOKING_UNEXPECTED_ERROR + OrderErrorCode.ORDER_UNEXPECTED_ERROR } - throw OrderException(errorCode, e.message ?: errorCode.message, trial) + throw OrderException(errorCode, e.message ?: errorCode.message) } } - private fun getTrialAfterValidateCanConfirm(reservationId: Long): Long { + private fun validateCanConfirm(reservationId: Long) { log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" } val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId) val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId) try { orderValidator.validateCanConfirm(reservation, schedule) - - return getTrialIfSuccessAttemptNotExists(reservationId).also { - log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" } - } } catch (e: OrderException) { val errorCode = OrderErrorCode.NOT_CONFIRMABLE throw OrderException(errorCode, e.message) } } - - private fun getTrialIfSuccessAttemptNotExists(reservationId: Long): Long { - val paymentAttempts: List = paymentAttemptRepository.findAllByReservationId(reservationId) - - if (paymentAttempts.any { it.result == AttemptResult.SUCCESS }) { - log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservationId}" } - throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) - } - - return paymentAttempts.size.toLong() - } - - private fun requestConfirmPayment( - reservationId: Long, - paymentConfirmRequest: PaymentConfirmRequest - ): PaymentGatewayResponse { - log.info { "[requestConfirmPayment] 결제 및 이력 저장 시작: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" } - val paymentResponse: PaymentGatewayResponse - var attempt: PaymentAttemptEntity? = null - - try { - paymentResponse = paymentService.requestConfirm(paymentConfirmRequest) - - attempt = PaymentAttemptEntity( - id = idGenerator.create(), - reservationId = reservationId, - result = AttemptResult.SUCCESS, - ) - } catch (e: Exception) { - val errorCode: String = if (e is PaymentException) { - log.info { "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" } - e.errorCode.name - } else { - log.warn { - "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" - } - OrderErrorCode.BOOKING_UNEXPECTED_ERROR.name - } - - attempt = PaymentAttemptEntity( - id = idGenerator.create(), - reservationId = reservationId, - result = AttemptResult.FAILED, - errorCode = errorCode, - message = e.message - ) - - throw e - } finally { - val savedAttempt: PaymentAttemptEntity? = attempt?.let { - log.info { "[requestPayment] 결제 요청 이력 저장 시작: id=${it.id}, reservationId=${it.reservationId}, result=${it.result}, errorCode=${it.errorCode}, message=${it.message}" } - paymentAttemptRepository.save(it) - } - savedAttempt?.also { - log.info { "[requestPayment] 결제 요청 이력 저장 완료: id=${savedAttempt.id}" } - } ?: run { - log.info { "[requestPayment] 결제 요청 이력 저장 실패: reservationId=${reservationId}" } - } - } - - return paymentResponse - } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt index 7be57c88..b2d724b8 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt @@ -3,7 +3,6 @@ package com.sangdol.roomescape.order.business import com.sangdol.common.utils.KoreaDateTime import com.sangdol.roomescape.order.exception.OrderErrorCode import com.sangdol.roomescape.order.exception.OrderException -import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository import com.sangdol.roomescape.reservation.dto.ReservationStateResponse import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse @@ -29,7 +28,7 @@ class OrderValidator { when (reservation.status) { ReservationStatus.CONFIRMED -> { log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" } - throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) + throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED) } ReservationStatus.EXPIRED -> { log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt index 4ed09f47..7bc94cc4 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt @@ -9,11 +9,11 @@ enum class OrderErrorCode( override val message: String ) : ErrorCode { NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."), - BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."), + ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."), EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."), CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."), PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."), - BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.") + ORDER_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.") ; } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt index 2306f9d4..42b8b60e 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderException.kt @@ -6,11 +6,4 @@ import com.sangdol.common.types.exception.RoomescapeException class OrderException( override val errorCode: ErrorCode, override val message: String = errorCode.message, - var trial: Long = 0 ) : RoomescapeException(errorCode, message) - -class OrderErrorResponse( - val code: String, - val message: String, - val trial: Long -) -- 2.47.2 From 9fe576af1161150ba1b72f7ee14fedb53ebd9336 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 15:29:20 +0900 Subject: [PATCH 15/17] =?UTF-8?q?remove:=20OrderService=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=ED=99=94=EB=A1=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=EB=90=9C=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/OrderPostProcessorService.kt | 60 ------------------- .../order/exception/OrderExceptionHandler.kt | 45 -------------- .../persistence/PaymentAttemptEntity.kt | 38 ------------ .../persistence/PaymentAttemptRepository.kt | 28 --------- .../persistence/PostOrderTaskEntity.kt | 16 ----- .../persistence/PostOrderTaskRepository.kt | 6 -- .../roomescape/payment/dto/PaymentWriteDTO.kt | 5 -- 7 files changed, 198 deletions(-) delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt delete mode 100644 service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt deleted file mode 100644 index f2fde854..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderPostProcessorService.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.sangdol.roomescape.order.business - -import com.sangdol.common.persistence.IDGenerator -import com.sangdol.common.persistence.TransactionExecutionUtil -import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskEntity -import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository -import com.sangdol.roomescape.payment.business.PaymentService -import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse -import com.sangdol.roomescape.reservation.business.ReservationService -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Propagation -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -private val log: KLogger = KotlinLogging.logger {} - -@Service -class OrderPostProcessorService( - private val idGenerator: IDGenerator, - private val reservationService: ReservationService, - private val paymentService: PaymentService, - private val postOrderTaskRepository: PostOrderTaskRepository, - private val transactionExecutionUtil: TransactionExecutionUtil -) { - @Transactional(propagation = Propagation.REQUIRES_NEW) - fun processAfterPaymentConfirmation( - reservationId: Long, - paymentResponse: PaymentGatewayResponse - ) { - val paymentKey = paymentResponse.paymentKey - try { - log.info { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } - - val paymentCreateResponse = paymentService.savePayment(reservationId, paymentResponse) - reservationService.confirmReservation(reservationId) - - log.info { - "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 완료: reservationId=${reservationId}, paymentKey=${paymentKey}, paymentId=${paymentCreateResponse.paymentId}, paymentDetailId=${paymentCreateResponse.detailId}" - } - } catch (e: Exception) { - log.warn(e) { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 실패. 작업 저장 시작: reservationId=${reservationId}, paymentKey=$paymentKey}" } - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - PostOrderTaskEntity( - id = idGenerator.create(), - reservationId = reservationId, - paymentKey = paymentKey, - trial = 1, - nextRetryAt = Instant.now().plusSeconds(30), - ).also { - postOrderTaskRepository.save(it) - } - } - - log.info { "[processAfterPaymentConfirmation] 작업 저장 완료" } - } - } -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt deleted file mode 100644 index 908450a3..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderExceptionHandler.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.sangdol.roomescape.order.exception - -import com.sangdol.common.types.exception.ErrorCode -import com.sangdol.common.types.web.HttpStatus -import com.sangdol.common.web.support.log.WebLogMessageConverter -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import jakarta.servlet.http.HttpServletRequest -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice - -private val log: KLogger = KotlinLogging.logger {} - -@RestControllerAdvice -class OrderExceptionHandler( - private val messageConverter: WebLogMessageConverter -) { - @ExceptionHandler(OrderException::class) - fun handleOrderException( - servletRequest: HttpServletRequest, - e: OrderException - ): ResponseEntity { - val errorCode: ErrorCode = e.errorCode - val httpStatus: HttpStatus = errorCode.httpStatus - val errorResponse = OrderErrorResponse( - code = errorCode.errorCode, - message = if (httpStatus.isClientError()) e.message else errorCode.message, - trial = e.trial - ) - - log.info { - messageConverter.convertToErrorResponseMessage( - servletRequest = servletRequest, - httpStatus = httpStatus, - responseBody = errorResponse, - exception = if (errorCode.message == e.message) null else e - ) - } - - return ResponseEntity - .status(httpStatus.value()) - .body(errorResponse) - } -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt deleted file mode 100644 index 26de715c..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptEntity.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.sangdol.roomescape.order.infrastructure.persistence - -import com.sangdol.common.persistence.PersistableBaseEntity -import jakarta.persistence.* -import org.springframework.data.annotation.CreatedBy -import org.springframework.data.annotation.CreatedDate -import org.springframework.data.jpa.domain.support.AuditingEntityListener -import java.time.Instant - -@Entity -@EntityListeners(AuditingEntityListener::class) -@Table(name = "payment_attempts") -class PaymentAttemptEntity( - id: Long, - - val reservationId: Long, - - @Enumerated(value = EnumType.STRING) - val result: AttemptResult, - - @Column(columnDefinition = "VARCHAR(50)") - val errorCode: String? = null, - - @Column(columnDefinition = "TEXT") - val message: String? = null, -) : PersistableBaseEntity(id) { - @Column(updatable = false) - @CreatedDate - lateinit var createdAt: Instant - - @Column(updatable = false) - @CreatedBy - var createdBy: Long = 0L -} - -enum class AttemptResult { - SUCCESS, FAILED -} \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt deleted file mode 100644 index 4f2ff58f..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.sangdol.roomescape.order.infrastructure.persistence - -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query - -interface PaymentAttemptRepository: JpaRepository { - - fun countByReservationId(reservationId: Long): Long - - @Query( - """ - SELECT - CASE - WHEN COUNT(pa) > 0 - THEN TRUE - ELSE FALSE - END - FROM - PaymentAttemptEntity pa - WHERE - pa.reservationId = :reservationId - AND pa.result = com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult.SUCCESS - """ - ) - fun isSuccessAttemptExists(reservationId: Long): Boolean - - fun findAllByReservationId(reservationId: Long): List -} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt deleted file mode 100644 index 730ca5c8..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskEntity.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.sangdol.roomescape.order.infrastructure.persistence - -import com.sangdol.common.persistence.PersistableBaseEntity -import jakarta.persistence.Entity -import jakarta.persistence.Table -import java.time.Instant - -@Entity -@Table(name = "post_order_tasks") -class PostOrderTaskEntity( - id: Long, - val reservationId: Long, - val paymentKey: String, - val trial: Int, - val nextRetryAt: Instant -) : PersistableBaseEntity(id) \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt deleted file mode 100644 index 356215e8..00000000 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PostOrderTaskRepository.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.sangdol.roomescape.order.infrastructure.persistence - -import org.springframework.data.jpa.repository.JpaRepository - -interface PostOrderTaskRepository : JpaRepository { -} \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt index 2d89c07c..e11d2b20 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/payment/dto/PaymentWriteDTO.kt @@ -8,11 +8,6 @@ data class PaymentConfirmRequest( val amount: Int, ) -data class PaymentCreateResponse( - val paymentId: Long, - val detailId: Long -) - data class PaymentCancelRequest( val reservationId: Long, val cancelReason: String, -- 2.47.2 From 385f98fb21bb100a77c1bf36f93ea46f8f99f3db Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 15:29:34 +0900 Subject: [PATCH 16/17] =?UTF-8?q?test:=20PaymentServiceTest=EC=97=90=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=88=98=EB=8B=A8=EB=B3=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/payment/PaymentServiceTest.kt | 104 ++++++++++++++---- 1 file changed, 82 insertions(+), 22 deletions(-) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt index c1a20307..b1973107 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/payment/PaymentServiceTest.kt @@ -2,10 +2,11 @@ package com.sangdol.roomescape.payment import com.ninjasquad.springmockk.MockkBean import com.sangdol.roomescape.payment.business.PaymentService -import com.sangdol.roomescape.payment.business.domain.PaymentMethod -import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode +import com.sangdol.roomescape.payment.business.domain.* import com.sangdol.roomescape.payment.business.event.PaymentEvent import com.sangdol.roomescape.payment.business.event.PaymentEventListener +import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest +import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentException @@ -31,31 +32,67 @@ class PaymentServiceTest( context("결제를 승인한다.") { val request = PaymentFixture.confirmRequest - test("결제 정상 승인 및 이벤트 발행 확인") { - val tosspayAPIResponse = PaymentFixture.confirmResponse( - paymentKey = request.paymentKey, - amount = request.amount, - orderId = request.orderId, - method = PaymentMethod.CARD - ) + context("결제 정상 승인 및 이벤트 발행 확인") { + test("간편결제 + 카드") { + val tosspayAPIResponse = PaymentFixture.confirmResponse( + paymentKey = request.paymentKey, + amount = request.amount, + orderId = request.orderId, + method = PaymentMethod.EASY_PAY, + cardDetail = PaymentFixture.cardDetail(100_000), + easyPayDetail = PaymentFixture.easypayDetail(0) + ) - val paymentEventSlot = slot() + runSuccessTest(request, tosspayAPIResponse) { + assertSoftly(it.detail) { + this::class shouldBe EasypayCardPaymentDetail::class + } + } + } - every { - tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) - } returns tosspayAPIResponse + test("간편결제 - 충전식") { + val tosspayAPIResponse = PaymentFixture.confirmResponse( + paymentKey = request.paymentKey, + amount = request.amount, + orderId = request.orderId, + method = PaymentMethod.EASY_PAY, + ) - every { - paymentEventListener.handlePaymentEvent(capture(paymentEventSlot)) - } just runs + runSuccessTest(request, tosspayAPIResponse) { + assertSoftly(it.detail) { + this::class shouldBe EasypayPrepaidPaymentDetail::class + } + } + } - paymentService.requestConfirm(12345L, request) + test("카드") { + val tosspayAPIResponse = PaymentFixture.confirmResponse( + paymentKey = request.paymentKey, + amount = request.amount, + orderId = request.orderId, + method = PaymentMethod.CARD, + ) - assertSoftly(paymentEventSlot.captured) { - this.paymentKey shouldBe request.paymentKey - this.orderId shouldBe request.orderId - this.totalAmount shouldBe request.amount - this.method shouldBe PaymentMethod.CARD + runSuccessTest(request, tosspayAPIResponse) { + assertSoftly(it.detail) { + this::class shouldBe CardPaymentDetail::class + } + } + } + + test("계좌이체") { + val tosspayAPIResponse = PaymentFixture.confirmResponse( + paymentKey = request.paymentKey, + amount = request.amount, + orderId = request.orderId, + method = PaymentMethod.TRANSFER, + ) + + runSuccessTest(request, tosspayAPIResponse) { + assertSoftly(it.detail) { + this::class shouldBe BankTransferPaymentDetail::class + } + } } } @@ -122,4 +159,27 @@ class PaymentServiceTest( } } } + + private fun runSuccessTest(request: PaymentConfirmRequest, tosspayAPIResponse: PaymentGatewayResponse, additionalAssertion: (PaymentEvent) -> Unit): PaymentEvent { + val paymentEventSlot = slot() + + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } returns tosspayAPIResponse + + every { + paymentEventListener.handlePaymentEvent(capture(paymentEventSlot)) + } just runs + + paymentService.requestConfirm(12345L, request) + + assertSoftly(paymentEventSlot.captured) { + this.paymentKey shouldBe request.paymentKey + this.orderId shouldBe request.orderId + this.totalAmount shouldBe request.amount + this.method shouldBe tosspayAPIResponse.method + } + + return paymentEventSlot.captured + } } -- 2.47.2 From b636ac926ef866af68d301dd139444e4de50f7da Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 16 Oct 2025 15:29:49 +0900 Subject: [PATCH 17/17] =?UTF-8?q?test:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20Ord?= =?UTF-8?q?erService=EC=9D=98=20=ED=99=95=EC=A0=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sangdol/roomescape/order/OrderApiTest.kt | 150 ++++++------------ .../roomescape/order/OrderConcurrencyTest.kt | 14 +- .../roomescape/supports/DummyInitializer.kt | 21 +++ 3 files changed, 71 insertions(+), 114 deletions(-) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt index 5b0fd1be..acd2937a 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderApiTest.kt @@ -1,46 +1,38 @@ package com.sangdol.roomescape.order -import com.ninjasquad.springmockk.SpykBean +import com.ninjasquad.springmockk.MockkBean import com.sangdol.common.utils.KoreaDate import com.sangdol.common.utils.KoreaTime import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.order.exception.OrderErrorCode -import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult -import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity -import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository -import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository -import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.payment.business.event.PaymentEvent +import com.sangdol.roomescape.payment.business.event.PaymentEventListener import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse +import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.exception.PaymentErrorCode -import com.sangdol.roomescape.payment.exception.PaymentException -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository -import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository +import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient +import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent +import com.sangdol.roomescape.reservation.business.event.ReservationEventListener import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import io.kotest.assertions.assertSoftly -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import io.mockk.every +import io.mockk.* import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus class OrderApiTest( - @SpykBean private val paymentService: PaymentService, - private val paymentAttemptRepository: PaymentAttemptRepository, + @MockkBean(relaxed = true) private val paymentClient: TosspayClient, + @MockkBean(relaxed = true) private val reservationEventListener: ReservationEventListener, + @MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener, private val reservationRepository: ReservationRepository, - private val postOrderTaskRepository: PostOrderTaskRepository, - private val scheduleRepository: ScheduleRepository, - private val paymentRepository: PaymentRepository, - private val paymentDetailRepository: PaymentDetailRepository ) : FunSpecSpringbootTest() { val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest @@ -82,39 +74,52 @@ class OrderApiTest( } test("정상 응답") { - val reservation = dummyInitializer.createPendingReservation(user) + val reservationId = dummyInitializer.createPendingReservation(user).id + + val reservationConfirmEventSlot = slot() + val paymentEventSlot = slot() every { - paymentService.requestConfirm(paymentRequest) + paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount) } returns expectedPaymentResponse + every { + paymentEventListener.handlePaymentEvent(capture(paymentEventSlot)) + } just runs + + every { + reservationEventListener.handleReservationConfirmEvent(capture(reservationConfirmEventSlot)) + } just runs + runTest( token = token, using = { body(paymentRequest) }, on = { - post("/orders/${reservation.id}/confirm") + post("/orders/${reservationId}/confirm") }, expect = { statusCode(HttpStatus.OK.value()) } ) - assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) { - this.status shouldBe ScheduleStatus.RESERVED - this.holdExpiredAt shouldBe null - } - reservationRepository.findByIdOrNull(reservation.id)!!.status shouldBe ReservationStatus.CONFIRMED - - assertSoftly(paymentRepository.findByReservationId(reservation.id)) { - this.shouldNotBeNull() - this.status shouldBe expectedPaymentResponse.status - - paymentDetailRepository.findByPaymentId(this.id)!!.shouldNotBeNull() + verify(exactly = 1) { + paymentEventListener.handlePaymentEvent(any()) + }.also { + assertSoftly(paymentEventSlot.captured) { + this.paymentKey shouldBe expectedPaymentResponse.paymentKey + this.reservationId shouldBe reservationId + } } - paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue() + verify(exactly = 1) { + reservationEventListener.handleReservationConfirmEvent(any()) + }.also { + assertSoftly(reservationConfirmEventSlot.captured) { + this.reservationId shouldBe reservationId + } + } } context("검증 과정에서의 실패 응답") { @@ -128,24 +133,6 @@ class OrderApiTest( ) } - test("이미 결제가 완료된 예약이면 실패한다.") { - val reservation = dummyInitializer.createPendingReservation(user) - - paymentAttemptRepository.save(PaymentAttemptEntity( - id = IDGenerator.create(), - reservationId = reservation.id, - result = AttemptResult.SUCCESS - )) - - runExceptionTest( - token = token, - method = HttpMethod.POST, - endpoint = "/orders/${reservation.id}/confirm", - requestBody = paymentRequest, - expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE - ) - } - test("이미 확정된 예약이면 실패한다.") { val reservation = dummyInitializer.createConfirmReservation(user) @@ -223,68 +210,23 @@ class OrderApiTest( } context("결제 과정에서의 실패 응답.") { - test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") { - val reservation = dummyInitializer.createPendingReservation(user) + test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태가 된다.") { + val reservationId = dummyInitializer.createPendingReservation(user).id every { - paymentService.requestConfirm(paymentRequest) - } throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR) + paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount) + } throws ExternalPaymentException(400, "INVALID_REQUEST", "잘못 요청함") runExceptionTest( token = token, method = HttpMethod.POST, - endpoint = "/orders/${reservation.id}/confirm", + endpoint = "/orders/${reservationId}/confirm", requestBody = paymentRequest, expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR - ).also { - it.extract().path("trial") shouldBe 0 - } - - assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { - this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS - } - - val paymentAttempt = paymentAttemptRepository.findAll().first { it.reservationId == reservation.id } - assertSoftly(paymentAttempt) { - it.shouldNotBeNull() - it.result shouldBe AttemptResult.FAILED - it.errorCode shouldBe PaymentErrorCode.PAYMENT_CLIENT_ERROR.name - } - } - } - - context("결제 성공 이후 실패 응답.") { - test("결제 이력 저장 과정에서 예외가 발생하면 해당 작업을 저장하며, 사용자는 정상 응답을 받는다.") { - val reservation = dummyInitializer.createPendingReservation(user) - - every { - paymentService.requestConfirm(paymentRequest) - } returns expectedPaymentResponse - - every { - paymentService.savePayment(reservation.id, expectedPaymentResponse) - } throws RuntimeException("결제 저장 실패!") - - runTest( - token = token, - using = { - body(paymentRequest) - }, - on = { - post("/orders/${reservation.id}/confirm") - }, - expect = { - statusCode(HttpStatus.OK.value()) - } ) - paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue() - - val postOrderTask = postOrderTaskRepository.findAll().first { it.reservationId == reservation.id } - assertSoftly(postOrderTask) { - it.shouldNotBeNull() - it.paymentKey shouldBe paymentRequest.paymentKey - it.trial shouldBe 1 + assertSoftly(reservationRepository.findByIdOrNull(reservationId)!!) { + this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS } } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt index 768323d7..6a72317a 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt @@ -18,6 +18,7 @@ import com.sangdol.roomescape.supports.ReservationFixture import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import io.kotest.assertions.assertSoftly import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import io.mockk.every import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -69,7 +70,7 @@ class OrderConcurrencyTest( test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") { every { - paymentService.requestConfirm(paymentConfirmRequest) + paymentService.requestConfirm(reservation.id, paymentConfirmRequest) } returns paymentGatewayResponse withContext(Dispatchers.IO) { @@ -87,18 +88,13 @@ class OrderConcurrencyTest( } assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { - this.status shouldBe ReservationStatus.CONFIRMED - } - - assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.RESERVED - this.holdExpiredAt shouldBe null + this.status shouldNotBe ReservationStatus.EXPIRED } } test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") { every { - paymentService.requestConfirm(paymentConfirmRequest) + paymentService.requestConfirm(reservation.id, paymentConfirmRequest) } returns paymentGatewayResponse withContext(Dispatchers.IO) { @@ -113,8 +109,6 @@ class OrderConcurrencyTest( async { assertThrows { orderService.confirm(reservation.id, paymentConfirmRequest) - }.also { - it.trial shouldBe 0 } } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index 324d9f29..d099c9f8 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -157,6 +157,27 @@ class DummyInitializer( } } + fun createExpiredOrCanceledReservation( + user: UserEntity, + status: ReservationStatus, + storeId: Long = IDGenerator.create(), + themeRequest: ThemeCreateRequest = ThemeFixture.createRequest, + scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, + reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, + ): ReservationEntity { + return createPendingReservation(user, storeId, themeRequest, scheduleRequest, reservationRequest).apply { + this.status = status + }.also { + reservationRepository.save(it) + + scheduleRepository.findByIdOrNull(it.scheduleId)?.let { schedule -> + schedule.status = ScheduleStatus.AVAILABLE + schedule.holdExpiredAt = null + scheduleRepository.save(schedule) + } + } + } + fun createPayment( reservationId: Long, request: PaymentConfirmRequest = PaymentFixture.confirmRequest, -- 2.47.2