Compare commits

..

4 Commits

76 changed files with 2024 additions and 1173 deletions

View File

@ -1,9 +1,29 @@
FROM amazoncorretto:17
FROM gradle:8-jdk17 AS dependencies
WORKDIR /app
COPY service/build/libs/service.jar app.jar
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
EXPOSE 8080
COPY --from=builder /app/service/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# issue-pr-template
공통으로 사용하게 될 이슈, PR 템플릿 저장소

View File

@ -1,6 +0,0 @@
#!/bin/bash
IMAGE_NAME="roomescape-backend"
IMAGE_TAG=$1
./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push

845
query.md Normal file
View File

@ -0,0 +1,845 @@
## Auth
**로그인**
```sql
-- 회원
-- 이메일로 회원 조회
SELECT
u.id
FROM
users u
WHERE
u.email = ?
LIMIT 1;
-- 연락처로 회원 조회
SELECT
u.id
FROM
users u
WHERE
u.phone = ?
LIMIT 1;
-- 회원 추가
INSERT INTO users (
created_at, created_by, email, name, password, phone, region_code,
status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 회원 상태 이력 추가
INSERT INTO user_status_history (
created_at, created_by, reason, status, updated_at, updated_by,
user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
);
```
### Payment
**결제 승인 & 저장**
```sql
-- 결제 정보 추가
INSERT INTO payment ( approved_at, method, order_id, payment_key, requested_at, reservation_id, status, total_amount, type, id
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 결제 상세 정보 추가
INSERT INTO payment_detail ( payment_id, supplied_amount, vat, id
) VALUES ( ?, ?, ?, ?
);
-- 카드 결제 상세 정보 추가
INSERT INTO payment_card_detail ( amount, approval_number, card_number, card_type, easypay_discount_amount, easypay_provider_code, installment_plan_months, is_interest_free, issuer_code, owner_type, id
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**결제 취소**
SQL
```sql
-- 예약 ID로 결제 정보 조회
SELECT
p.id,
p.approved_at,
p.method,
p.order_id,
p.payment_key,
p.requested_at,
p.reservation_id,
p.status,
p.total_amount,
p.type
FROM
payment p
WHERE
p.reservation_id = ?;
-- 추가
-- 취소된 결제 정보 추가
INSERT INTO canceled_payment (
cancel_amount, cancel_reason, canceled_at, canceled_by,
card_discount_amount, easypay_discount_amount, payment_id,
requested_at, transfer_discount_amount, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
### Region
**모든 시/도 조회**
```sql
SELECT DISTINCT
r.sido_code,
r.sido_name
FROM
region r
ORDER BY
r.sido_name;
```
**시/군/구 조회**
```sql
SELECT
r.sigungu_code,
r.sigungu_name
FROM
region r
WHERE
r.sido_code = ?
GROUP BY
r.sigungu_code, r.sigungu_name
ORDER BY
r.sigungu_name;
```
**지역 코드 조회**
```sql
SELECT
r.code
FROM
region r
WHERE
r.sido_code = ? AND r.sigungu_code = ?;
```
### Reservation
**Pending 예약 생성**
```sql
-- schedule 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- theme 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 예약 추가
INSERT INTO reservation (
created_at, created_by, participant_count, requirement,
reserver_contact, reserver_name, schedule_id, status,
updated_at, updated_by, user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**확정**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 예약 확정
UPDATE
reservation
SET
participant_count = ?, requirement = ?, reserver_contact = ?,
reserver_name = ?, schedule_id = ?, status = ?,
updated_at = ?, updated_by = ?, user_id = ?
WHERE
id = ?;
-- Schedule 확정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**취소**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 취소 예약 추가
INSERT INTO canceled_reservation (
cancel_reason, canceled_at, canceled_by,
reservation_id, status, id
) VALUES (
?, ?, ?, ?, ?, ?
);
-- 예약 취소
UPDATE
reservation
SET
participant_count = ?, requirement = ?, reserver_contact = ?,
reserver_name = ?, schedule_id = ?, status = ?,
updated_at = ?, updated_by = ?, user_id = ?
WHERE
id = ?;
-- 일정 활성화
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**회원 예약 조회**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.user_id = ? AND r.status IN (?, ?);
-- 일정 조회 -> 각 예약별 1개씩(N개)
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id
JOIN store st ON st.id = s.store_id
WHERE
s.id = ?;
```
**예약 상세 조회**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 회원 연락처 정보 조회
SELECT
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
FROM
users u
WHERE
u.id = ?;
-- 결제 정보 조회
SELECT
p.id, p.approved_at, p.method, p.order_id, p.payment_key,
p.requested_at, p.reservation_id, p.status, p.total_amount, p.type
FROM
payment p
WHERE
p.reservation_id = ?;
-- 결제 상세 정보 조회
SELECT
pd.id,
CASE
WHEN pbt.id IS NOT NULL THEN 1 -- bank_transfer
WHEN pcd.id IS NOT NULL THEN 2 -- card
WHEN pep.id IS NOT NULL THEN 3 -- easypay
WHEN pd.id IS NOT NULL THEN 0 -- etc
END AS payment_type,
pd.payment_id, pd.supplied_amount, pd.vat,
pbt.bank_code, pbt.settlement_status,
pcd.amount, pcd.approval_number, pcd.card_number, pcd.card_type,
pcd.easypay_discount_amount, pcd.easypay_provider_code,
pcd.installment_plan_months, pcd.is_interest_free, pcd.issuer_code,
pcd.owner_type,
pep.amount AS easypay_amount,
pep.discount_amount AS easypay_discount_amount,
pep.easypay_provider_code AS easypay_provider
FROM
payment_detail pd
LEFT JOIN payment_bank_transfer_detail pbt ON pd.id = pbt.id
LEFT JOIN payment_card_detail pcd ON pd.id = pcd.id
LEFT JOIN payment_easypay_prepaid_detail pep ON pd.id = pep.id
WHERE
pd.payment_id = ?;
-- 취소 결제 정보 조회
SELECT
cp.id, cp.cancel_amount, cp.cancel_reason, cp.canceled_at,
cp.canceled_by, cp.card_discount_amount, cp.easypay_discount_amount,
cp.payment_id, cp.requested_at, cp.transfer_discount_amount
FROM
canceled_payment cp
WHERE
cp.payment_id = ?;
```
### Schedule
**날짜, 시간, 테마로 조회**
```sql
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR t.id = ?)
JOIN store st ON st.id = s.store_id AND st.id = ?
WHERE
s.date = ?
```
**감사 정보 조회**
```sql
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 작업자 조회(createdBy, updatedBy)
SELECT
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
a.updated_by
FROM
admin a
WHERE
a.id = ?;
```
**일정 생성**
```sql
-- 날짜, 시간, 테마가 같은 일정 존재 여부 확인
SELECT EXISTS (
SELECT 1
FROM schedule s
WHERE
s.store_id = ?
AND s.date = ?
AND s.theme_id = ?
AND s.time = ?
);
-- 시간이 겹치는 같은 날의 일정이 있는지 확인
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR s.theme_id = ?)
JOIN store st ON st.id = s.store_id AND st.id = ?
WHERE
s.date = ?
-- 일정 추가
INSERT INTO schedule (
created_at, created_by, date, status, store_id,
theme_id, time, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**일정 수정**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 수정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**일정 삭제**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 삭제
DELETE FROM schedule
WHERE id = ?;
```
**상태 → HOLD 변경**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 수정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
### Store
**매장 상세 조회**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 지역 정보 조회
SELECT
r.code, r.sido_code, r.sido_name, r.sigungu_code, r.sigungu_name
FROM
region r
WHERE
r.code = ?;
-- 감사 정보 조회(createdBy, updatedBy)
SELECT
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
a.updated_by
FROM
admin a
WHERE
a.id = ?;
```
**매장 등록**
```sql
-- 이름 중복 확인
SELECT s.id FROM store s WHERE s.name = ? LIMIT 1;
-- 연락처 중복 확인
SELECT s.id FROM store s WHERE s.contact = ? LIMIT 1;
-- 주소 중복 확인
SELECT s.id FROM store s WHERE s.address = ? LIMIT 1;
-- 사업자번호 중복 확인
SELECT s.id FROM store s WHERE s.business_reg_num = ? LIMIT 1;
-- 추가
INSERT INTO store (
address, business_reg_num, contact, created_at, created_by,
name, region_code, status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**매장 수정**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 수정
UPDATE
store
SET
address = ?, business_reg_num = ?, contact = ?, name = ?,
region_code = ?, status = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**비활성화(status = DISABLE)**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 수정
UPDATE
store
SET
address = ?, business_reg_num = ?, contact = ?, name = ?,
region_code = ?, status = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**모든 매장 조회**
```sql
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.status = 'ACTIVE'
AND (? IS NULL OR s.region_code LIKE ?);
```
**개별 매장 상세 조회**
```sql
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
```
### Theme
**생성**
```sql
-- 이름으로 조회
SELECT
t.id
FROM
theme t
WHERE
t.name = ?
LIMIT 1;
-- 추가
INSERT INTO theme (
available_minutes, created_at, created_by, description, difficulty,
expected_minutes_from, expected_minutes_to, is_active, max_participants,
min_participants, name, price, thumbnail_url, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**Active인 모든 테마 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.is_active = TRUE;
```
**테마 목록 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t;
```
**감사 정보 포함 개별 테마 상세 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
```
**개별 테마 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
```
**삭제**
```sql
-- 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 삭제
DELETE FROM theme WHERE id = ?;
```
**수정**
```sql
-- 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 수정
UPDATE
theme
SET
available_minutes = ?, description = ?, difficulty = ?,
expected_minutes_from = ?, expected_minutes_to = ?, is_active = ?,
max_participants = ?, min_participants = ?, name = ?, price = ?,
thumbnail_url = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**인기 테마 조회**
```sql
SELECT
t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price,
t.min_participants, t.max_participants,
t.available_minutes, t.expected_minutes_from, t.expected_minutes_to
FROM
theme t
JOIN (
SELECT
s.theme_id, count(*) as reservation_count
FROM
schedule s
JOIN
reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED'
WHERE
s.status = 'RESERVED'
AND (s.date BETWEEN :startFrom AND :endAt)
GROUP BY
s.theme_id
ORDER BY
reservation_count desc
LIMIT :count
) ranked_themes ON t.id = ranked_themes.theme_id
```
### User
**회원가입**
```sql
-- 이메일 중복 확인
SELECT
u.id
FROM
users u
WHERE
u.email = ?
LIMIT 1;
-- 연락처 중복 확인
SELECT
u.id
FROM
users u
WHERE
u.phone = ?
LIMIT 1;
-- 추가
INSERT INTO users (
created_at, created_by, email, name, password, phone, region_code,
status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 상태 변경 이력 추가
INSERT INTO user_status_history (
created_at, created_by, reason, status, updated_at, updated_by,
user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
);
```
**연락처 정보 조회**
```sql
SELECT
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
FROM
users u
WHERE
u.id = ?;
```

View File

@ -8,10 +8,6 @@ dependencies {
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
// Cache
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine")
// DB
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")

View File

@ -3,14 +3,8 @@ package com.sangdol.roomescape
import org.springframework.boot.Banner
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import java.util.*
@EnableAsync
@EnableCaching
@EnableScheduling
@SpringBootApplication(
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
)

View File

@ -20,7 +20,7 @@ class AdminService(
) {
@Transactional(readOnly = true)
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
log.info { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
return adminRepository.findByAccount(account)
?.let {
@ -28,14 +28,14 @@ class AdminService(
it.toCredentials()
}
?: run {
log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
log.info { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
}
}
@Transactional(readOnly = true)
fun findOperatorOrUnknown(id: Long): Auditor {
log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
log.info { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
return adminRepository.findByIdOrNull(id)?.let { admin ->
Auditor(admin.id, admin.name).also {

View File

@ -33,7 +33,7 @@ class AuthService(
request: LoginRequest,
context: LoginContext
): LoginSuccessResponse {
log.debug { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val (credentials, extraClaims) = getCredentials(request)
val event = LoginHistoryEvent(
@ -56,7 +56,10 @@ class AuthService(
} catch (e: Exception) {
eventPublisher.publishEvent(event.onFailure())
when (e) {
is AuthException -> { throw e }
is AuthException -> {
log.info { "[login] 로그인 실패: account = ${request.account}" }
throw e
}
else -> {
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
@ -71,7 +74,7 @@ class AuthService(
credentials: LoginCredentials
) {
if (credentials.password != request.password) {
log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
log.info { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
throw AuthException(AuthErrorCode.LOGIN_FAILED)
}
}

View File

@ -11,6 +11,8 @@ import jakarta.annotation.PreDestroy
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentLinkedQueue
@ -19,6 +21,8 @@ import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {}
@Component
@EnableAsync
@EnableScheduling
class LoginHistoryEventListener(
private val idGenerator: IDGenerator,
private val loginHistoryRepository: LoginHistoryRepository,
@ -31,7 +35,7 @@ class LoginHistoryEventListener(
@Async
@EventListener(classes = [LoginHistoryEvent::class])
fun onLoginCompleted(event: LoginHistoryEvent) {
log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
queue.add(event.toEntity(idGenerator.create())).also {
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
@ -44,10 +48,10 @@ class LoginHistoryEventListener(
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
fun flushScheduled() {
log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
log.info { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
return
}
flush()
@ -56,7 +60,7 @@ class LoginHistoryEventListener(
@PreDestroy
fun flushAll() {
log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
while (!queue.isEmpty()) {
flush()
}
@ -64,10 +68,10 @@ class LoginHistoryEventListener(
}
private fun flush() {
log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
log.info { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
log.info { "[flush] 큐에 있는 로그인 이력이 없음." }
return;
}

View File

@ -50,7 +50,7 @@ class JwtUtils(
val claims = extractAllClaims(token)
return claims.subject ?: run {
log.debug { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN)
}
}

View File

@ -6,7 +6,7 @@ import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.accessToken
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.user.business.UserService
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
@ -22,6 +22,7 @@ private val log: KLogger = KotlinLogging.logger {}
@Component
class UserContextResolver(
private val jwtUtils: JwtUtils,
private val userService: UserService,
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
@ -42,7 +43,7 @@ class UserContextResolver(
MdcPrincipalIdUtil.set(it)
}.toLong()
return CurrentUserContext(id = id)
return userService.findContextById(id)
} catch (e: Exception) {
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)

View File

@ -1,5 +1,6 @@
package com.sangdol.roomescape.common.types
data class CurrentUserContext(
val id: Long
val id: Long,
val name: String,
)

View File

@ -0,0 +1,60 @@
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] 작업 저장 완료" }
}
}
}

View File

@ -1,69 +1,146 @@
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 eventPublisher: ApplicationEventPublisher
private val paymentAttemptRepository: PaymentAttemptRepository,
private val orderPostProcessorService: OrderPostProcessorService
) {
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
var trial: Long = 0
val paymentKey = paymentConfirmRequest.paymentKey
log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
try {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
validateCanConfirm(reservationId)
reservationService.markInProgress(reservationId)
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)
}
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
val paymentClientResponse: PaymentGatewayResponse =
requestConfirmPayment(reservationId, paymentConfirmRequest)
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse)
} catch (e: Exception) {
val errorCode: ErrorCode = if (e is RoomescapeException) {
e.errorCode
} else {
OrderErrorCode.ORDER_UNEXPECTED_ERROR
OrderErrorCode.BOOKING_UNEXPECTED_ERROR
}
throw OrderException(errorCode, e.message ?: errorCode.message)
throw OrderException(errorCode, e.message ?: errorCode.message, trial)
}
}
private fun validateCanConfirm(reservationId: Long) {
log.debug { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
private fun getTrialAfterValidateCanConfirm(reservationId: Long): 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<PaymentAttemptEntity> = 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
}
}

View File

@ -3,6 +3,7 @@ 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
@ -27,12 +28,15 @@ class OrderValidator {
private fun validateReservationStatus(reservation: ReservationStateResponse) {
when (reservation.status) {
ReservationStatus.CONFIRMED -> {
throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED)
log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
}
ReservationStatus.EXPIRED -> {
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
}
ReservationStatus.CANCELED -> {
log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
}
else -> {}
@ -41,14 +45,14 @@ class OrderValidator {
private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
if (schedule.status != ScheduleStatus.HOLD) {
log.debug { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
log.info { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
}
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
val nowDateTime = KoreaDateTime.now()
if (scheduleDateTime.isBefore(nowDateTime)) {
log.debug { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" }
log.info { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" }
throw OrderException(OrderErrorCode.PAST_SCHEDULE)
}
}

View File

@ -9,11 +9,11 @@ enum class OrderErrorCode(
override val message: String
) : ErrorCode {
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
ORDER_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
;
}

View File

@ -6,4 +6,11 @@ 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
)

View File

@ -0,0 +1,45 @@
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<OrderErrorResponse> {
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)
}
}

View File

@ -0,0 +1,38 @@
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
}

View File

@ -0,0 +1,28 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface PaymentAttemptRepository: JpaRepository<PaymentAttemptEntity, Long> {
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<PaymentAttemptEntity>
}

View File

@ -0,0 +1,16 @@
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)

View File

@ -0,0 +1,6 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface PostOrderTaskRepository : JpaRepository<PostOrderTaskEntity, Long> {
}

View File

@ -1,6 +1,5 @@
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.*
@ -9,12 +8,9 @@ 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
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -22,20 +18,18 @@ 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
) {
fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse {
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
try {
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
eventPublisher.publishEvent(it.toEvent(reservationId))
log.info { "[requestConfirm] 결제 및 이벤트 발행 완료: paymentKey=${request.paymentKey}" }
log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" }
}
} catch (e: Exception) {
when(e) {
@ -62,6 +56,19 @@ 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)
@ -72,17 +79,12 @@ class PaymentService(
)
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
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}" }
}
paymentWriter.cancel(
userId = userId,
payment = payment,
requestedAt = request.requestedAt,
cancelResponse = clientCancelResponse
)
}.also {
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
}
@ -90,7 +92,7 @@ class PaymentService(
@Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
log.debug { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
@ -99,13 +101,11 @@ class PaymentService(
return payment?.toResponse(
detail = paymentDetail?.toResponse(),
cancel = cancelDetail?.toResponse()
).also {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
}
)
}
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
@ -116,7 +116,7 @@ class PaymentService(
}
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
.also {
@ -129,7 +129,7 @@ class PaymentService(
}
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId).also {
if (it != null) {
@ -141,7 +141,7 @@ class PaymentService(
}
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
return canceledPaymentRepository.findByPaymentId(paymentId).also {
if (it == null) {

View File

@ -0,0 +1,80 @@
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}" }
}
}
}

View File

@ -1,38 +0,0 @@
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()

View File

@ -1,22 +0,0 @@
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
)

View File

@ -1,44 +0,0 @@
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.debug { "[handlePaymentEvent] 결제 정보 저장 이벤트 수신: reservationId=${reservationId}, paymentKey=${event.paymentKey}" }
val paymentId = idGenerator.create()
val paymentEntity: PaymentEntity = event.toEntity(paymentId)
paymentRepository.save(paymentEntity)
val paymentDetailId = idGenerator.create()
val paymentDetailEntity: PaymentDetailEntity = event.toDetailEntity(id = paymentDetailId, paymentId = paymentId)
paymentDetailRepository.save(paymentDetailEntity)
log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 처리 완료: reservationId=${reservationId}, paymentId=${paymentId}, paymentDetailId=${paymentDetailId}" }
}
}

View File

@ -2,8 +2,11 @@ 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
@ -13,6 +16,13 @@ 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<CommonApiResponse<PaymentGatewayResponse>>
@Operation(summary = "결제 취소")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun cancelPayment(

View File

@ -8,6 +8,11 @@ data class PaymentConfirmRequest(
val amount: Int,
)
data class PaymentCreateResponse(
val paymentId: Long,
val detailId: Long
)
data class PaymentCancelRequest(
val reservationId: Long,
val cancelReason: String,

View File

@ -33,7 +33,7 @@ class TosspayClient(
amount: Int,
): PaymentGatewayResponse {
val startTime = System.currentTimeMillis()
log.debug { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
return confirmClient.request(paymentKey, orderId, amount)
.also {
@ -47,7 +47,7 @@ class TosspayClient(
cancelReason: String
): PaymentGatewayCancelResponse {
val startTime = System.currentTimeMillis()
log.debug { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
return cancelClient.request(paymentKey, amount, cancelReason).also {
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }

View File

@ -1,14 +1,82 @@
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
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.*
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,
@ -26,88 +94,3 @@ 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
)
}

View File

@ -1,91 +0,0 @@
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
)
}

View File

@ -1,53 +0,0 @@
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)
}
}

View File

@ -6,18 +6,27 @@ 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.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/payments")
class PaymentController(
private val paymentService: PaymentService
) : PaymentAPI {
@PostMapping("/confirm")
override fun confirmPayment(
@Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>> {
val response = paymentService.requestConfirm(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/cancel")
override fun cancelPayment(
@User user: CurrentUserContext,

View File

@ -17,7 +17,7 @@ class RegionService(
) {
@Transactional(readOnly = true)
fun readAllSido(): SidoListResponse {
log.debug { "[readAllSido] 모든 시/도 조회 시작" }
log.info { "[readAllSido] 모든 시/도 조회 시작" }
val result: List<Pair<String, String>> = regionRepository.readAllSido()
if (result.isEmpty()) {
@ -32,7 +32,7 @@ class RegionService(
@Transactional(readOnly = true)
fun findSigunguBySido(sidoCode: String): SigunguListResponse {
log.debug { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
log.info { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
if (result.isEmpty()) {
@ -47,7 +47,7 @@ class RegionService(
@Transactional(readOnly = true)
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse {
log.debug { "[findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
log.info { "[findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let {
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
@ -60,7 +60,7 @@ class RegionService(
@Transactional(readOnly = true)
fun findRegionInfo(regionCode: String): RegionInfoResponse {
log.debug { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
log.info { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
return regionRepository.findByCode(regionCode)?.let {
log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }

View File

@ -45,7 +45,7 @@ class ReservationService(
user: CurrentUserContext,
request: PendingReservationCreateRequest
): PendingReservationCreateResponse {
log.debug { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
run {
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
@ -64,7 +64,7 @@ class ReservationService(
@Transactional
fun confirmReservation(id: Long) {
log.debug { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
log.info { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id)
run {
@ -81,7 +81,7 @@ class ReservationService(
@Transactional
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
log.debug { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
log.info { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
val reservation: ReservationEntity = findOrThrow(reservationId)
@ -100,7 +100,7 @@ class ReservationService(
@Transactional(readOnly = true)
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
log.debug { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
log.info { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
userId = user.id,
@ -125,7 +125,7 @@ class ReservationService(
@Transactional(readOnly = true)
fun findDetailById(id: Long): ReservationAdditionalResponse {
log.debug { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id)
val user: UserContactResponse = userService.findContactById(reservation.userId)
@ -141,7 +141,7 @@ class ReservationService(
@Transactional(readOnly = true)
fun findStatusWithLock(id: Long): ReservationStateResponse {
log.debug { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdForUpdate(id)?.let {
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" }
@ -154,7 +154,7 @@ class ReservationService(
@Transactional
fun markInProgress(reservationId: Long) {
log.debug { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
findOrThrow(reservationId).apply {
this.status = ReservationStatus.PAYMENT_IN_PROGRESS
@ -164,7 +164,7 @@ class ReservationService(
}
private fun findOrThrow(id: Long): ReservationEntity {
log.debug { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }

View File

@ -1,15 +1,17 @@
package com.sangdol.roomescape.reservation.business
import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.common.utils.toKoreaDateTime
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import java.time.Instant
import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {}

View File

@ -1,5 +0,0 @@
package com.sangdol.roomescape.reservation.business.event
class ReservationConfirmEvent(
val reservationId: Long
)

View File

@ -1,34 +0,0 @@
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.debug { "[handleReservationConfirmEvent] 예약 확정 이벤트 수신: reservationId=${reservationId}" }
val modifiedRows = reservationRepository.confirmReservation(Instant.now(), reservationId)
if (modifiedRows == 0) {
log.warn { "[handleReservationConfirmEvent] 예상치 못한 예약 확정 실패 - 변경된 row 없음: reservationId=${reservationId}" }
}
log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 처리 완료" }
}
}

View File

@ -5,6 +5,7 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleReposi
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
@ -14,6 +15,7 @@ import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {}
@Component
@EnableScheduling
class IncompletedReservationScheduler(
private val scheduleRepository: ScheduleRepository,
private val reservationRepository: ReservationRepository
@ -22,10 +24,10 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional
fun processExpiredHoldSchedule() {
log.debug { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
log.debug { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
}
scheduleRepository.releaseHeldSchedules(targets).also {
@ -36,7 +38,7 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional
fun processExpiredReservation() {
log.debug { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }

View File

@ -16,8 +16,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
@Query(
"""
@Query("""
SELECT
r.id
FROM
@ -27,8 +27,7 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
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<Long>
@Modifying
@ -48,23 +47,4 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
""", nativeQuery = true
)
fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): 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
}

View File

@ -34,7 +34,7 @@ class AdminScheduleService(
) {
@Transactional(readOnly = true)
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
log.debug { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
val searchDate = date ?: KoreaDate.today()
@ -44,12 +44,14 @@ class AdminScheduleService(
.sortedBy { it.time }
return schedules.toAdminSummaryResponse()
.also { log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } }
.also {
log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findScheduleAudit(id: Long): AuditingInfo {
log.debug { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
@ -62,7 +64,7 @@ class AdminScheduleService(
@Transactional
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
log.debug { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
scheduleValidator.validateCanCreate(storeId, request)
@ -77,12 +79,14 @@ class AdminScheduleService(
}
return ScheduleCreateResponse(schedule.id)
.also { log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" } }
.also {
log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
}
}
@Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.debug { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
@ -100,7 +104,7 @@ class AdminScheduleService(
@Transactional
fun deleteSchedule(id: Long) {
log.debug { "[deleteSchedule] 일정 삭제 시작: id=$id" }
log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanDelete(it)
@ -112,7 +116,7 @@ class AdminScheduleService(
}
private fun findOrThrow(id: Long): ScheduleEntity {
log.debug { "[findOrThrow] 일정 조회 시작: id=$id" }
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
return scheduleRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }

View File

@ -30,12 +30,13 @@ class ScheduleService(
) {
@Transactional(readOnly = true)
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
log.debug { "[getStoreScheduleByDate] 매장 일정 조회 시작: storeId=${storeId}, date=$date" }
log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
val currentDate: LocalDate = KoreaDate.today()
val currentTime: LocalTime = KoreaTime.now()
if (date.isBefore(currentDate)) {
log.warn { "[getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" }
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
}
@ -43,7 +44,6 @@ class ScheduleService(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
.filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
return schedules.toResponseWithTheme()
.also {
log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" }
@ -52,7 +52,7 @@ class ScheduleService(
@Transactional
fun holdSchedule(id: Long) {
log.debug { "[holdSchedule] 일정 Holding 시작: id=$id" }
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
val schedule = findForUpdateOrThrow(id).also {
scheduleValidator.validateCanHold(it)
@ -69,7 +69,7 @@ class ScheduleService(
@Transactional(readOnly = true)
fun findStateWithLock(id: Long): ScheduleStateResponse {
log.debug { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
?: run {
@ -95,7 +95,7 @@ class ScheduleService(
@Transactional
fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) {
log.debug { "[reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
log.info { "[reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also {
log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
@ -103,7 +103,7 @@ class ScheduleService(
}
private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
log.debug { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
return scheduleRepository.findByIdForUpdate(id)
?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }

View File

@ -59,7 +59,7 @@ class ScheduleValidator(
private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
log.debug {
log.info {
"[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
}
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
@ -71,7 +71,7 @@ class ScheduleValidator(
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
if (inputDateTime.isBefore(now)) {
log.debug {
log.info {
"[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
}
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
@ -82,7 +82,7 @@ class ScheduleValidator(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
.firstOrNull { it.containsTime(time) }
?.let {
log.debug { "[validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" }
log.info { "[validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT)
}
}

View File

@ -1,6 +1,7 @@
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
@ -159,4 +160,18 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
"""
)
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): 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<ScheduleWithThemeId>
}

View File

@ -35,7 +35,7 @@ class StoreService(
) {
@Transactional(readOnly = true)
fun getDetail(id: Long): StoreDetailResponse {
log.debug { "[getDetail] 매장 상세 조회 시작: id=${id}" }
log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id)
val region = regionService.findRegionInfo(store.regionCode)
@ -47,7 +47,7 @@ class StoreService(
@Transactional
fun register(request: StoreRegisterRequest): StoreRegisterResponse {
log.debug { "[register] 매장 등록 시작: name=${request.name}" }
log.info { "[register] 매장 등록 시작: name=${request.name}" }
storeValidator.validateCanRegister(request)
@ -70,7 +70,7 @@ class StoreService(
@Transactional
fun update(id: Long, request: StoreUpdateRequest) {
log.debug { "[update] 매장 수정 시작: id=${id}, request=${request}" }
log.info { "[update] 매장 수정 시작: id=${id}, request=${request}" }
storeValidator.validateCanUpdate(request)
@ -83,7 +83,7 @@ class StoreService(
@Transactional
fun disableById(id: Long) {
log.debug { "[inactive] 매장 비활성화 시작: id=${id}" }
log.info { "[inactive] 매장 비활성화 시작: id=${id}" }
findOrThrow(id).apply {
this.disable()
@ -94,7 +94,7 @@ class StoreService(
@Transactional(readOnly = true)
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse {
log.debug { "[getAllActiveStores] 전체 매장 조회 시작" }
log.info { "[getAllActiveStores] 전체 매장 조회 시작" }
val regionCode: String? = when {
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
@ -108,7 +108,7 @@ class StoreService(
@Transactional(readOnly = true)
fun findStoreInfo(id: Long): StoreInfoResponse {
log.debug { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
log.info { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id)
@ -117,7 +117,7 @@ class StoreService(
}
private fun getAuditInfo(store: StoreEntity): AuditingInfo {
log.debug { "[getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" }
log.info { "[getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" }
val createdBy = adminService.findOperatorOrUnknown(store.createdBy)
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
@ -132,7 +132,7 @@ class StoreService(
}
private fun findOrThrow(id: Long): StoreEntity {
log.debug { "[findOrThrow] 매장 조회 시작: id=${id}" }
log.info { "[findOrThrow] 매장 조회 시작: id=${id}" }
return storeRepository.findActiveStoreById(id)
?.also {

View File

@ -31,21 +31,21 @@ class StoreValidator(
private fun validateDuplicateNameExist(name: String) {
if (storeRepository.existsByName(name)) {
log.debug { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
log.info { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED)
}
}
private fun validateDuplicateContactExist(contact: String) {
if (storeRepository.existsByContact(contact)) {
log.debug { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
log.info { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED)
}
}
private fun validateDuplicateAddressExist(address: String) {
if (storeRepository.existsByAddress(address)) {
log.debug { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
log.info { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED)
}
}

View File

@ -42,9 +42,4 @@ class TestSetupController(
fun findAllStoreIds(): StoreIdList {
return testSetupService.findAllStores()
}
@GetMapping("/reservations-with-user")
fun findAllReservationsWithUser(): ReservationWithUserList {
return testSetupService.findAllReservationWithUser()
}
}

View File

@ -45,13 +45,3 @@ data class StoreIdList(
data class StoreId(
val storeId: Long
)
data class ReservationWithUser(
val account: String,
val password: String,
val reservationId: Long
)
data class ReservationWithUserList(
val results: List<ReservationWithUser>
)

View File

@ -1,50 +0,0 @@
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<UserEntity, Long> {
/**
* for test
*/
@Query("""
SELECT * FROM users u LIMIT :count
""", nativeQuery = true)
fun findUsersByCount(count: Long): List<UserEntity>
}
interface TestSetupScheduleRepository: JpaRepository<ScheduleEntity, Long> {
/**
* 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<ScheduleWithThemeId>
}
interface TestSetupReservationRepository: JpaRepository<ReservationEntity, Long> {
/**
* 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<ReservationWithUser>
}

View File

@ -3,8 +3,10 @@ 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
@ -14,9 +16,8 @@ class TestSetupService(
private val themeRepository: ThemeRepository,
private val storeRepository: StoreRepository,
private val adminRepository: AdminRepository,
private val userRepository: TestSetupUserRepository,
private val scheduleRepository: TestSetupScheduleRepository,
private val reservationRepository: TestSetupReservationRepository
private val userRepository: UserRepository,
private val scheduleRepository: ScheduleRepository,
) {
@Transactional(readOnly = true)
@ -84,11 +85,4 @@ class TestSetupService(
StoreId(it.id)
})
}
@Transactional(readOnly = true)
fun findAllReservationWithUser(): ReservationWithUserList {
return ReservationWithUserList(
reservationRepository.findAllReservationWithUser()
)
}
}

View File

@ -3,18 +3,22 @@ package com.sangdol.roomescape.theme.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.theme.dto.*
import com.sangdol.roomescape.theme.dto.ThemeDetailResponse
import com.sangdol.roomescape.theme.dto.ThemeSummaryListResponse
import com.sangdol.roomescape.theme.dto.ThemeNameListResponse
import com.sangdol.roomescape.theme.dto.ThemeCreateRequest
import com.sangdol.roomescape.theme.dto.ThemeCreateResponse
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.exception.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.mapper.toDetailResponse
import com.sangdol.roomescape.theme.mapper.toSummaryListResponse
import com.sangdol.roomescape.theme.mapper.toEntity
import com.sangdol.roomescape.theme.mapper.toNameListResponse
import com.sangdol.roomescape.theme.mapper.toSummaryListResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.cache.annotation.CacheEvict
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -30,7 +34,7 @@ class AdminThemeService(
) {
@Transactional(readOnly = true)
fun findThemeSummaries(): ThemeSummaryListResponse {
log.debug { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findAll()
.toSummaryListResponse()
@ -39,7 +43,7 @@ class AdminThemeService(
@Transactional(readOnly = true)
fun findThemeDetail(id: Long): ThemeDetailResponse {
log.debug { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id)
@ -53,7 +57,7 @@ class AdminThemeService(
@Transactional(readOnly = true)
fun findActiveThemes(): ThemeNameListResponse {
log.debug { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
return themeRepository.findActiveThemes()
.toNameListResponse()
@ -65,7 +69,7 @@ class AdminThemeService(
@Transactional
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.debug { "[createTheme] 테마 생성 시작: name=${request.name}" }
log.info { "[createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request)
@ -77,10 +81,10 @@ class AdminThemeService(
}
}
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
@Transactional
fun deleteTheme(id: Long) {
log.debug { "[deleteTheme] 테마 삭제 시작: id=${id}" }
log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id)
@ -89,10 +93,9 @@ class AdminThemeService(
}
}
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
@Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.debug { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
@ -121,7 +124,7 @@ class AdminThemeService(
}
private fun findOrThrow(id: Long): ThemeEntity {
log.debug { "[findOrThrow] 테마 조회 시작: id=$id" }
log.info { "[findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }

View File

@ -10,8 +10,6 @@ import com.sangdol.roomescape.theme.mapper.toInfoResponse
import com.sangdol.roomescape.theme.mapper.toListResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.micrometer.core.instrument.MeterRegistry
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -23,19 +21,13 @@ private val log: KLogger = KotlinLogging.logger {}
@Service
class ThemeService(
private val themeRepository: ThemeRepository,
meterRegistry: MeterRegistry
private val themeRepository: ThemeRepository
) {
private val themeDetailQueryRequestCount = meterRegistry.counter("theme.detail.query.requested")
@Cacheable(cacheNames = ["theme-details"], key="#id")
@Transactional(readOnly = true)
fun findInfoById(id: Long): ThemeInfoResponse {
log.debug { "[findInfoById] 테마 조회 시작: id=$id" }
log.info { "[findInfoById] 테마 조회 시작: id=$id" }
val theme = themeRepository.findByIdOrNull(id)?.also {
themeDetailQueryRequestCount.increment()
} ?: run {
val theme = themeRepository.findByIdOrNull(id) ?: run {
log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
@ -46,7 +38,7 @@ class ThemeService(
@Transactional(readOnly = true)
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
log.debug { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
log.info { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(KoreaDate.today())
val previousWeekSaturday = previousWeekSunday.plusDays(6)

View File

@ -1,6 +1,7 @@
package com.sangdol.roomescape.user.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.user.dto.UserContactResponse
import com.sangdol.roomescape.user.dto.UserCreateRequest
import com.sangdol.roomescape.user.dto.UserCreateResponse
@ -27,9 +28,20 @@ class UserService(
private val userValidator: UserValidator,
private val idGenerator: IDGenerator
) {
@Transactional(readOnly = true)
fun findContextById(id: Long): CurrentUserContext {
log.info { "[findContextById] 현재 로그인된 회원 조회 시작: id=${id}" }
val user: UserEntity = findOrThrow(id)
return CurrentUserContext(user.id, user.name)
.also {
log.info { "[findContextById] 현재 로그인된 회원 조회 완료: id=${id}" }
}
}
@Transactional(readOnly = true)
fun findCredentialsByAccount(email: String): UserLoginCredentials {
log.debug { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
log.info { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
return userRepository.findByEmail(email)
?.let {
@ -37,13 +49,14 @@ class UserService(
it.toCredentials()
}
?: run {
log.info { "[findCredentialsByAccount] 회원 조회 실패" }
throw UserException(UserErrorCode.USER_NOT_FOUND)
}
}
@Transactional(readOnly = true)
fun findContactById(id: Long): UserContactResponse {
log.debug { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
log.info { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
val user = findOrThrow(id)
@ -55,7 +68,7 @@ class UserService(
@Transactional
fun signup(request: UserCreateRequest): UserCreateResponse {
log.debug { "[signup] 회원가입 시작: request:$request" }
log.info { "[signup] 회원가입 시작: request:$request" }
userValidator.validateCanSignup(request.email, request.phone)

View File

@ -15,14 +15,14 @@ class UserValidator(
) {
fun validateCanSignup(email: String, phone: String) {
log.debug { "[UserValidator.validateCanSignup] 회원가입 가능 여부 검증 시작: email:$email / phone:$phone" }
log.info { "[UserValidator.validateCanSignup] 회원가입 가능 여부 검증 시작: email:$email / phone:$phone" }
if (userRepository.existsByEmail(email)) {
log.debug { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
log.info { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS)
}
if (userRepository.existsByPhone(phone)) {
log.debug { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
log.info { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS)
}
}

View File

@ -1,12 +1,21 @@
package com.sangdol.roomescape.user.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface UserRepository : JpaRepository<UserEntity, Long> {
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<UserEntity>
}
interface UserStatusHistoryRepository : JpaRepository<UserStatusHistoryEntity, Long>

View File

@ -16,9 +16,6 @@ spring:
jdbc:
batch_size: ${JDBC_BATCH_SIZE:100}
order_inserts: true
cache:
type: caffeine
cache-names: ${CACHE_NAMES:theme-details}
management:
endpoints:
@ -31,7 +28,7 @@ management:
show-details: always
payment:
api-base-url: ${PAYMENT_SERVER_ENDPOINT:http://localhost:8000}
api-base-url: ${PAYMENT_SERVER_ENDPOINT:https://api.tosspayments.com}
springdoc:
swagger-ui:

View File

@ -1,4 +1,4 @@
package com.sangdol.roomescape.auth
package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity

View File

@ -1,38 +1,46 @@
package com.sangdol.roomescape.order
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
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.infrastructure.client.TosspayClient
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
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.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.*
import io.mockk.every
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
class OrderApiTest(
@MockkBean(relaxed = true) private val paymentClient: TosspayClient,
@MockkBean(relaxed = true) private val reservationEventListener: ReservationEventListener,
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
@SpykBean private val paymentService: PaymentService,
private val paymentAttemptRepository: PaymentAttemptRepository,
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
@ -74,52 +82,39 @@ class OrderApiTest(
}
test("정상 응답") {
val reservationId = dummyInitializer.createPendingReservation(user).id
val reservationConfirmEventSlot = slot<ReservationConfirmEvent>()
val paymentEventSlot = slot<PaymentEvent>()
val reservation = dummyInitializer.createPendingReservation(user)
every {
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
paymentService.requestConfirm(paymentRequest)
} 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/${reservationId}/confirm")
post("/orders/${reservation.id}/confirm")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
verify(exactly = 1) {
paymentEventListener.handlePaymentEvent(any())
}.also {
assertSoftly(paymentEventSlot.captured) {
this.paymentKey shouldBe expectedPaymentResponse.paymentKey
this.reservationId shouldBe reservationId
}
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) {
reservationEventListener.handleReservationConfirmEvent(any())
}.also {
assertSoftly(reservationConfirmEventSlot.captured) {
this.reservationId shouldBe reservationId
}
}
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue()
}
context("검증 과정에서의 실패 응답") {
@ -133,6 +128,24 @@ 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)
@ -210,23 +223,68 @@ class OrderApiTest(
}
context("결제 과정에서의 실패 응답.") {
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태가 된다.") {
val reservationId = dummyInitializer.createPendingReservation(user).id
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") {
val reservation = dummyInitializer.createPendingReservation(user)
every {
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
} throws ExternalPaymentException(400, "INVALID_REQUEST", "잘못 요청함")
paymentService.requestConfirm(paymentRequest)
} throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR)
runExceptionTest(
token = token,
method = HttpMethod.POST,
endpoint = "/orders/${reservationId}/confirm",
endpoint = "/orders/${reservation.id}/confirm",
requestBody = paymentRequest,
expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
).also {
it.extract().path<Long>("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())
}
)
assertSoftly(reservationRepository.findByIdOrNull(reservationId)!!) {
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
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
}
}
}

View File

@ -18,7 +18,6 @@ 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
@ -70,7 +69,7 @@ class OrderConcurrencyTest(
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
every {
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
paymentService.requestConfirm(paymentConfirmRequest)
} returns paymentGatewayResponse
withContext(Dispatchers.IO) {
@ -88,13 +87,18 @@ class OrderConcurrencyTest(
}
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
this.status shouldNotBe ReservationStatus.EXPIRED
this.status shouldBe ReservationStatus.CONFIRMED
}
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
this.status shouldBe ScheduleStatus.RESERVED
this.holdExpiredAt shouldBe null
}
}
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
every {
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
paymentService.requestConfirm(paymentConfirmRequest)
} returns paymentGatewayResponse
withContext(Dispatchers.IO) {
@ -109,6 +113,8 @@ class OrderConcurrencyTest(
async {
assertThrows<OrderException> {
orderService.confirm(reservation.id, paymentConfirmRequest)
}.also {
it.trial shouldBe 0
}
}
}

View File

@ -4,21 +4,22 @@ 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.PaymentMethod
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
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.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.payment.mapper.toDetailEntity
import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.payment.mapper.toEvent
import com.sangdol.roomescape.supports.*
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 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
@ -27,10 +28,211 @@ 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"
@ -43,6 +245,16 @@ class PaymentAPITest(
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
requestBody = PaymentFixture.cancelRequest,
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
}
test("정상 취소") {
@ -50,7 +262,7 @@ class PaymentAPITest(
val reservation = dummyInitializer.createConfirmReservation(user = user)
val confirmRequest = PaymentFixture.confirmRequest
val paymentEntity = createPayment(
val paymentCreateResponse = createPayment(
request = confirmRequest,
reservationId = reservation.id
)
@ -77,10 +289,10 @@ class PaymentAPITest(
statusCode(HttpStatus.OK.value())
}
).also {
val payment = paymentRepository.findByIdOrNull(paymentEntity.id)
val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId)
?: throw AssertionError("Unexpected Exception Occurred.")
val canceledPayment =
canceledPaymentRepository.findByPaymentId(paymentEntity.id)
canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId)
?: throw AssertionError("Unexpected Exception Occurred.")
payment.status shouldBe PaymentStatus.CANCELED
@ -107,7 +319,7 @@ class PaymentAPITest(
private fun createPayment(
request: PaymentConfirmRequest,
reservationId: Long,
): PaymentEntity {
): PaymentCreateResponse {
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} returns PaymentFixture.confirmResponse(
@ -119,10 +331,49 @@ class PaymentAPITest(
transferDetail = null,
)
val paymentEvent = paymentService.requestConfirm(reservationId, request).toEvent(reservationId)
val paymentResponse = paymentService.requestConfirm(request)
return paymentService.savePayment(reservationId, paymentResponse)
}
return paymentRepository.save(paymentEvent.toEntity(IDGenerator.create())).also {
paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), it.id))
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("결제타입 확인 필요.")
}
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())
}
)
}
}

View File

@ -1,54 +0,0 @@
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
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
}
}
}
}

View File

@ -1,185 +0,0 @@
package com.sangdol.roomescape.payment
import com.ninjasquad.springmockk.MockkBean
import com.sangdol.roomescape.payment.business.PaymentService
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
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
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)
)
runSuccessTest(request, tosspayAPIResponse) {
assertSoftly(it.detail) {
this::class shouldBe EasypayCardPaymentDetail::class
}
}
}
test("간편결제 - 충전식") {
val tosspayAPIResponse = PaymentFixture.confirmResponse(
paymentKey = request.paymentKey,
amount = request.amount,
orderId = request.orderId,
method = PaymentMethod.EASY_PAY,
)
runSuccessTest(request, tosspayAPIResponse) {
assertSoftly(it.detail) {
this::class shouldBe EasypayPrepaidPaymentDetail::class
}
}
}
test("카드") {
val tosspayAPIResponse = PaymentFixture.confirmResponse(
paymentKey = request.paymentKey,
amount = request.amount,
orderId = request.orderId,
method = 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
}
}
}
}
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<PaymentException> {
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<PaymentException> {
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<PaymentException> {
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<PaymentException> {
paymentService.requestConfirm(12345L, request)
}.also {
it.errorCode shouldBe expectedErrorCode
it.message shouldBe expectedErrorCode.message
}
}
}
}
}
private fun runSuccessTest(request: PaymentConfirmRequest, tosspayAPIResponse: PaymentGatewayResponse, additionalAssertion: (PaymentEvent) -> Unit): PaymentEvent {
val paymentEventSlot = slot<PaymentEvent>()
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
}
}

View File

@ -49,6 +49,15 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
}
@ -176,6 +185,15 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
test("정상 응답") {
@ -220,6 +238,15 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
test("정상 응답") {
@ -288,6 +315,15 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
test("정상 응답") {
@ -341,6 +377,15 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
context("정상 응답") {

View File

@ -108,7 +108,7 @@ class ReservationConcurrencyTest(
private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long {
return reservationService.createPendingReservation(
user = CurrentUserContext(id = user.id),
user = CurrentUserContext(id = user.id, name = user.name),
request = PendingReservationCreateRequest(
scheduleId = schedule.id,
reserverName = user.name,

View File

@ -1,46 +0,0 @@
package com.sangdol.roomescape.reservation
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
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.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
}
}
}
}

View File

@ -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.*
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.infrastructure.persistence.CanceledPaymentEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.payment.mapper.toResponse
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
@ -33,8 +33,7 @@ class DummyInitializer(
private val scheduleRepository: ScheduleRepository,
private val reservationRepository: ReservationRepository,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository
private val paymentWriter: PaymentWriter
) {
fun createStore(
@ -205,10 +204,12 @@ class DummyInitializer(
transferDetail = transferDetail
)
val paymentEvent = clientConfirmResponse.toEvent(reservationId)
val payment = paymentRepository.save(paymentEvent.toEntity(IDGenerator.create()))
val payment = paymentWriter.createPayment(
reservationId = reservationId,
paymentGatewayResponse = clientConfirmResponse
)
val detail = paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), payment.id))
val detail = paymentWriter.createDetail(clientConfirmResponse, payment.id)
return payment.toResponse(detail = detail.toResponse(), cancel = null)
}
@ -226,14 +227,11 @@ class DummyInitializer(
cancelReason = cancelReason,
)
return clientCancelResponse.cancels.toEntity(
id = IDGenerator.create(),
paymentId = payment.id,
cancelRequestedAt = Instant.now(),
canceledBy = userId
).also {
canceledPaymentRepository.save(it)
}
return paymentWriter.cancel(
userId,
payment,
requestedAt = Instant.now(),
clientCancelResponse
)
}
}

View File

@ -1,8 +1,7 @@
package com.sangdol.roomescape.supports
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
import com.sangdol.roomescape.payment.business.PaymentWriter
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
@ -44,7 +43,13 @@ abstract class FunSpecSpringbootTest(
}
}) {
@Autowired
lateinit var testAuthUtil: TestAuthUtil
private lateinit var userRepository: UserRepository
@Autowired
private lateinit var adminRepository: AdminRepository
@Autowired
private lateinit var storeRepository: StoreRepository
@Autowired
lateinit var dummyInitializer: DummyInitializer
@ -52,40 +57,32 @@ 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,
paymentRepository: PaymentRepository,
paymentDetailRepository: PaymentDetailRepository,
canceledPaymentRepository: CanceledPaymentRepository
paymentWriter: PaymentWriter,
paymentRepository: PaymentRepository
): DummyInitializer {
return DummyInitializer(
themeRepository = themeRepository,
scheduleRepository = scheduleRepository,
reservationRepository = reservationRepository,
paymentWriter = paymentWriter,
paymentRepository = paymentRepository,
storeRepository = storeRepository,
paymentDetailRepository = paymentDetailRepository,
canceledPaymentRepository = canceledPaymentRepository
storeRepository = storeRepository
)
}
}

View File

@ -6,28 +6,21 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.supports.*
import com.sangdol.roomescape.supports.ThemeFixture.createRequest
import com.sangdol.roomescape.theme.business.AdminThemeService
import com.sangdol.roomescape.theme.business.MIN_DURATION
import com.sangdol.roomescape.theme.business.MIN_PARTICIPANTS
import com.sangdol.roomescape.theme.business.MIN_PRICE
import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.cache.CacheManager
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
class AdminThemeApiTest(
private val themeRepository: ThemeRepository,
private val themeService: ThemeService,
private val cacheManager: CacheManager
private val themeRepository: ThemeRepository
) : FunSpecSpringbootTest() {
init {
@ -489,19 +482,14 @@ class AdminThemeApiTest(
}
}
test("정상 삭제 및 캐시 제거 확인") {
test("정상 삭제") {
val token = testAuthUtil.defaultHqAdminLogin().second
val createdTheme = initialize("테스트를 위한 테마 생성") {
dummyInitializer.createTheme()
}
initialize("테마 캐시 추가") {
themeService.findInfoById(createdTheme.id)
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).shouldNotBeNull()
}
runTest(
token = token,
token = testAuthUtil.defaultHqAdminLogin().second,
on = {
delete("/admin/themes/${createdTheme.id}")
},
@ -510,7 +498,6 @@ class AdminThemeApiTest(
}
).also {
themeRepository.findByIdOrNull(createdTheme.id) shouldBe null
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java) shouldBe null
}
}
@ -579,7 +566,7 @@ class AdminThemeApiTest(
val updateRequest = ThemeUpdateRequest(name = "modified")
test("정상 수정 및 감사 정보 & 캐시 변경 확인") {
test("정상 수정 및 감사 정보 변경 확인") {
val createdThemeId: Long = initialize("테스트를 위한 관리자1의 테마 생성") {
runTest(
token = testAuthUtil.defaultHqAdminLogin().second,
@ -595,11 +582,6 @@ class AdminThemeApiTest(
).extract().path("data.id")
}
initialize("테마 캐시 추가") {
themeService.findInfoById(createdThemeId)
cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java).shouldNotBeNull()
}
val (otherAdmin, otherAdminToken) = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") {
testAuthUtil.adminLogin(
AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE)
@ -622,12 +604,6 @@ class AdminThemeApiTest(
updatedTheme.name shouldBe updateRequest.name
updatedTheme.updatedBy shouldBe otherAdmin.id
// 캐시 제거 확인
assertSoftly(cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java)) {
this shouldBe null
}
}
}

View File

@ -10,32 +10,23 @@ import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.mapper.toEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainInOrder
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.comparables.shouldBeLessThan
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.cache.CacheManager
import org.springframework.http.HttpMethod
import java.time.LocalDate
class ThemeApiTest(
private val themeRepository: ThemeRepository,
private val cacheManager: CacheManager
private val themeRepository: ThemeRepository
) : FunSpecSpringbootTest() {
init {
context("ID로 테마 정보를 조회한다.") {
test("정상 응답 및 캐시 저장 확인") {
test("정상 응답") {
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
dummyInitializer.createTheme()
}
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).also {
it shouldBe null
}
runTest(
on = {
get("/themes/${createdTheme.id}")
@ -52,15 +43,6 @@ class ThemeApiTest(
)
}
)
assertSoftly(cacheManager.getCache("theme-details")) {
this.shouldNotBeNull()
val themeFromCache = this.get(createdTheme.id, ThemeInfoResponse::class.java)
themeFromCache.shouldNotBeNull()
themeFromCache.id shouldBe createdTheme.id
}
}
test("테마가 없으면 실패한다.") {

View File

@ -1,68 +0,0 @@
package com.sangdol.roomescape.theme
import com.ninjasquad.springmockk.MockkBean
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.IDGenerator
import com.sangdol.roomescape.supports.initialize
import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import io.mockk.every
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.springframework.data.repository.findByIdOrNull
import java.util.concurrent.CountDownLatch
class ThemeConcurrencyTest(
private val themeService: ThemeService,
@MockkBean private val themeRepository: ThemeRepository,
) : FunSpecSpringbootTest() {
init {
test("동일한 테마에 대한 반복 조회 요청시, DB 요청은 1회만 발생한다.") {
val entity = ThemeEntity(
id = IDGenerator.create(),
name = "테스트입니다.",
description = "테스트에요!",
thumbnailUrl = "http://localhost:8080/hello",
difficulty = Difficulty.VERY_EASY,
price = 10000,
minParticipants = 3,
maxParticipants = 5,
availableMinutes = 90,
expectedMinutesFrom = 70,
expectedMinutesTo = 80,
isActive = true
)
every {
themeRepository.findByIdOrNull(entity.id)
} returns entity
initialize("캐시 등록") {
themeService.findInfoById(entity.id)
}
val requestCount = 64
withContext(Dispatchers.IO) {
val latch = CountDownLatch(requestCount)
(1..requestCount).map {
async {
latch.countDown()
latch.await()
themeService.findInfoById(entity.id)
}
}
}
verify(exactly = 1) {
themeRepository.findByIdOrNull(entity.id)
}
}
}
}

View File

@ -148,6 +148,15 @@ class UserApiTest(
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultStoreAdminLogin().second,
method = HttpMethod.GET,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
}
test("정상 응답") {

View File

@ -18,9 +18,6 @@ spring:
init:
mode: always
schema-locations: classpath:schema/schema-mysql.sql
cache:
type: caffeine
cache-names: ${CACHE_NAMES:theme-details}
security:
jwt:

View File

@ -22,7 +22,7 @@ export function parseIdToString(response) {
}
export function maxIterations() {
const maxIterationsRes = http.get(`http://localhost:8080/tests/max-iterations`)
const maxIterationsRes = http.get(`${BASE_URL}/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' }, tags: { name: '/auth/login' } }
const params = { headers: { 'Content-Type': 'application/json' } }
const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params)
@ -78,7 +78,7 @@ export function login(account, password, principalType) {
}
}
export function getHeaders(token, endpoint) {
export function getHeaders(token) {
const headers = {
'Content-Type': 'application/json',
};
@ -87,5 +87,5 @@ export function getHeaders(token, endpoint) {
headers['Authorization'] = `Bearer ${token}`;
}
return { headers: headers, tags: { name: endpoint } };
return { headers: headers };
}

View File

@ -15,13 +15,10 @@ export const options = {
scenarios: {
user_reservation: {
executor: 'ramping-vus',
startVUs: 0,
startVUs: 1500,
stages: [
{ duration: '3m', target: 500 },
{ duration: '2m', target: 1000 },
{ duration: '2m', target: 1500 },
{ duration: '3m', target: 1500 },
{ duration: '3m', target: 0 },
{ duration: '10m', target: 1500 },
{ duration: '1m', target: 0 }
]
}
},
@ -95,8 +92,7 @@ export default function (data) {
while (searchTrial < 5) {
storeId = randomItem(stores).storeId
targetDate = randomDayBetween(1, 7)
const params = getHeaders(accessToken, "/stores/${storeId}/schedules?date=${date}")
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`, params)
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
const result = check(res, {'일정 조회 성공': (r) => r.status === 200})
if (result !== true) {
continue
@ -122,7 +118,7 @@ export default function (data) {
const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate)
randomThemesForFetchDetail.forEach(id => {
http.get(`${BASE_URL}/themes/${id}`, getHeaders(accessToken, "/themes/${id}"))
http.get(`${BASE_URL}/themes/${id}`)
sleep(10)
})
})
@ -141,11 +137,11 @@ export default function (data) {
let isScheduleHeld = false
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken, "/schedules/${id}/hold"))
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken))
const body = JSON.parse(holdRes.body)
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`, { tag: { name: "/themes/${id}"}})
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`)
selectedThemeInfo = parseIdToString(themeInfoRes).data
isScheduleHeld = true
} else {
@ -164,7 +160,7 @@ export default function (data) {
group(`예약 정보 입력 페이지`, function () {
let userName, userContact
group(`회원 연락처 조회`, function () {
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken, "/users/contact"))
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken))
if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) {
throw new Error("회원 연락처 조회 과정에서 예외 발생")
@ -195,7 +191,7 @@ export default function (data) {
requirement: requirement
})
const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken, "/reservations/pending"))
const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken))
const responseBody = parseIdToString(pendingReservationCreateRes)
if (pendingReservationCreateRes.status !== 200) {
@ -233,7 +229,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, "/orders/${reservationId}/confirm"))
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken))
if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) {
isConfirmed = true

View File

@ -1,17 +1,17 @@
import http from 'k6/http';
import {check} from 'k6';
import {check, sleep} 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 = 200000;
const TOTAL_ITERATIONS = 85212;
export const options = {
scenarios: {
schedule_creation: {
executor: 'shared-iterations',
vus: 100,
vus: 263,
iterations: TOTAL_ITERATIONS,
maxDuration: '30m',
},
@ -97,7 +97,7 @@ function createSchedule(storeId, accessToken, schedule) {
time: schedule.time,
themeId: schedule.themeId,
});
const params = getHeaders(accessToken, "/admin/stores/${id}/schedules")
const params = getHeaders(accessToken)
const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params);
const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201});
@ -105,6 +105,7 @@ function createSchedule(storeId, accessToken, schedule) {
if (!success) {
console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`);
}
sleep(5)
return success;
}