generated from pricelees/issue-pr-template
Compare commits
45 Commits
main
...
refactor/#
| Author | SHA1 | Date | |
|---|---|---|---|
| 216bde2d25 | |||
| 97be1b8a1f | |||
| 308059b5b8 | |||
| 876725e0e1 | |||
| 60882bee85 | |||
| c76f6bba68 | |||
| ff516ef48f | |||
| 7482b3de99 | |||
| 7c02d9ceae | |||
| 7fe33d24d2 | |||
| d894750279 | |||
| 17fb44573d | |||
| edf4d3af24 | |||
| 6be9ae7efe | |||
| 985efbe0a3 | |||
| 25fc95fd2a | |||
| e6040fcd44 | |||
| 365a2a37ae | |||
| 5e77b1cf91 | |||
| c4cd168175 | |||
| 979623a670 | |||
| 1902fc6f7c | |||
| 1caa9d3f3d | |||
| f06bef8ea5 | |||
| 0820c0ccd9 | |||
| ab5edce38c | |||
| 07263426b2 | |||
| 7b0ebcc6dc | |||
| 5bd6250184 | |||
| 7bda14984e | |||
| 8bb22a6a84 | |||
| c4604ccdde | |||
| 038381424c | |||
| ef64a740c2 | |||
| dd406505ec | |||
| fd96bd9939 | |||
| 1652398fcc | |||
| b22d587757 | |||
| 6fa8c76b87 | |||
| 0a7bd85dc9 | |||
| 022742d1fa | |||
| 8378e10192 | |||
| 5bcba12a61 | |||
| 44c556776d | |||
| 1c700130c4 |
26
Dockerfile
26
Dockerfile
@ -1,9 +1,29 @@
|
|||||||
FROM amazoncorretto:17
|
FROM gradle:8-jdk17 AS dependencies
|
||||||
|
|
||||||
WORKDIR /app
|
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
|
EXPOSE 8080
|
||||||
|
|
||||||
|
COPY --from=builder /app/service/build/libs/*.jar app.jar
|
||||||
|
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
6
build.sh
6
build.sh
@ -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
|
|
||||||
@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
|||||||
import com.sangdol.common.web.asepct.ControllerLoggingAspect
|
import com.sangdol.common.web.asepct.ControllerLoggingAspect
|
||||||
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
|
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
|
||||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
import io.micrometer.tracing.CurrentTraceContext
|
|
||||||
import org.springframework.boot.web.servlet.FilterRegistrationBean
|
import org.springframework.boot.web.servlet.FilterRegistrationBean
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
@ -18,10 +17,9 @@ class WebLoggingConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
@DependsOn(value = ["webLogMessageConverter"])
|
@DependsOn(value = ["webLogMessageConverter"])
|
||||||
fun filterRegistrationBean(
|
fun filterRegistrationBean(
|
||||||
webLogMessageConverter: WebLogMessageConverter,
|
webLogMessageConverter: WebLogMessageConverter
|
||||||
currentTraceContext: CurrentTraceContext
|
|
||||||
): FilterRegistrationBean<OncePerRequestFilter> {
|
): FilterRegistrationBean<OncePerRequestFilter> {
|
||||||
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
|
val filter = HttpRequestLoggingFilter(webLogMessageConverter)
|
||||||
|
|
||||||
return FilterRegistrationBean<OncePerRequestFilter>(filter)
|
return FilterRegistrationBean<OncePerRequestFilter>(filter)
|
||||||
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
|
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import com.sangdol.common.utils.MdcStartTimeUtil
|
|||||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.micrometer.tracing.CurrentTraceContext
|
|
||||||
import jakarta.servlet.FilterChain
|
import jakarta.servlet.FilterChain
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
@ -16,8 +15,7 @@ import org.springframework.web.util.ContentCachingResponseWrapper
|
|||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
class HttpRequestLoggingFilter(
|
class HttpRequestLoggingFilter(
|
||||||
private val messageConverter: WebLogMessageConverter,
|
private val messageConverter: WebLogMessageConverter
|
||||||
private val currentTraceContext: CurrentTraceContext
|
|
||||||
) : OncePerRequestFilter() {
|
) : OncePerRequestFilter() {
|
||||||
override fun doFilterInternal(
|
override fun doFilterInternal(
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
@ -34,12 +32,9 @@ class HttpRequestLoggingFilter(
|
|||||||
try {
|
try {
|
||||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
filterChain.doFilter(cachedRequest, cachedResponse)
|
||||||
cachedResponse.copyBodyToResponse()
|
cachedResponse.copyBodyToResponse()
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
} finally {
|
} finally {
|
||||||
MdcStartTimeUtil.clear()
|
MdcStartTimeUtil.clear()
|
||||||
MdcPrincipalIdUtil.clear()
|
MdcPrincipalIdUtil.clear()
|
||||||
currentTraceContext.maybeScope(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
import apiClient from "@_api/apiClient";
|
|
||||||
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
|
|
||||||
|
|
||||||
export const confirm = async (
|
|
||||||
reservationId: string,
|
|
||||||
data: PaymentConfirmRequest,
|
|
||||||
): Promise<void> => {
|
|
||||||
return await apiClient.post<void>(
|
|
||||||
`/orders/${reservationId}/confirm`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface OrderErrorResponse {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
trial: number;
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { confirm } from '@_api/order/orderAPI';
|
import { confirm } from '@_api/order/orderAPI';
|
||||||
import type { OrderErrorResponse } from '@_api/order/orderTypes';
|
import type { BookingErrorResponse } from '@_api/order/orderTypes';
|
||||||
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
||||||
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
@ -83,7 +83,7 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
const error = err as AxiosError<OrderErrorResponse>;
|
const error = err as AxiosError<BookingErrorResponse>;
|
||||||
const errorCode = error.response?.data?.code;
|
const errorCode = error.response?.data?.code;
|
||||||
const errorMessage = error.response?.data?.message;
|
const errorMessage = error.response?.data?.message;
|
||||||
|
|
||||||
|
|||||||
845
query.md
Normal file
845
query.md
Normal 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 = ?;
|
||||||
|
```
|
||||||
@ -8,10 +8,6 @@ dependencies {
|
|||||||
// API docs
|
// API docs
|
||||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
|
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
|
// DB
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
runtimeOnly("com.mysql:mysql-connector-j")
|
runtimeOnly("com.mysql:mysql-connector-j")
|
||||||
|
|||||||
@ -3,14 +3,8 @@ package com.sangdol.roomescape
|
|||||||
import org.springframework.boot.Banner
|
import org.springframework.boot.Banner
|
||||||
import org.springframework.boot.SpringApplication
|
import org.springframework.boot.SpringApplication
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
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.*
|
import java.util.*
|
||||||
|
|
||||||
@EnableAsync
|
|
||||||
@EnableCaching
|
|
||||||
@EnableScheduling
|
|
||||||
@SpringBootApplication(
|
@SpringBootApplication(
|
||||||
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
|
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class AdminService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
|
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
|
||||||
log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
|
log.info { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
|
||||||
|
|
||||||
return adminRepository.findByAccount(account)
|
return adminRepository.findByAccount(account)
|
||||||
?.let {
|
?.let {
|
||||||
@ -28,14 +28,14 @@ class AdminService(
|
|||||||
it.toCredentials()
|
it.toCredentials()
|
||||||
}
|
}
|
||||||
?: run {
|
?: run {
|
||||||
log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
|
log.info { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
|
||||||
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
|
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findOperatorOrUnknown(id: Long): Auditor {
|
fun findOperatorOrUnknown(id: Long): Auditor {
|
||||||
log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
|
log.info { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
return adminRepository.findByIdOrNull(id)?.let { admin ->
|
return adminRepository.findByIdOrNull(id)?.let { admin ->
|
||||||
Auditor(admin.id, admin.name).also {
|
Auditor(admin.id, admin.name).also {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.sangdol.roomescape.auth.business
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
import com.sangdol.roomescape.admin.business.AdminService
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
import com.sangdol.roomescape.auth.dto.LoginContext
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||||
@ -13,8 +12,8 @@ import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
|||||||
import com.sangdol.roomescape.user.business.UserService
|
import com.sangdol.roomescape.user.business.UserService
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -26,37 +25,36 @@ const val CLAIM_STORE_ID_KEY = "store_id"
|
|||||||
class AuthService(
|
class AuthService(
|
||||||
private val adminService: AdminService,
|
private val adminService: AdminService,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
|
private val loginHistoryService: LoginHistoryService,
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
private val eventPublisher: ApplicationEventPublisher
|
|
||||||
) {
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
fun login(
|
fun login(
|
||||||
request: LoginRequest,
|
request: LoginRequest,
|
||||||
context: LoginContext
|
context: LoginContext
|
||||||
): LoginSuccessResponse {
|
): 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 (credentials, extraClaims) = getCredentials(request)
|
||||||
|
|
||||||
val event = LoginHistoryEvent(
|
|
||||||
id = credentials.id,
|
|
||||||
type = request.principalType,
|
|
||||||
ipAddress = context.ipAddress,
|
|
||||||
userAgent = context.userAgent
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
verifyPasswordOrThrow(request, credentials)
|
verifyPasswordOrThrow(request, credentials)
|
||||||
|
|
||||||
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
||||||
|
|
||||||
eventPublisher.publishEvent(event.onSuccess())
|
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
|
||||||
|
|
||||||
return credentials.toResponse(accessToken).also {
|
return credentials.toResponse(accessToken).also {
|
||||||
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
eventPublisher.publishEvent(event.onFailure())
|
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
|
||||||
|
|
||||||
when (e) {
|
when (e) {
|
||||||
is AuthException -> { throw e }
|
is AuthException -> {
|
||||||
|
log.info { "[login] 로그인 실패: account = ${request.account}" }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
||||||
@ -71,7 +69,7 @@ class AuthService(
|
|||||||
credentials: LoginCredentials
|
credentials: LoginCredentials
|
||||||
) {
|
) {
|
||||||
if (credentials.password != request.password) {
|
if (credentials.password != request.password) {
|
||||||
log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
log.info { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
||||||
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth.business
|
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
|
||||||
import com.sangdol.roomescape.auth.mapper.toEntity
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
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.Scheduled
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class LoginHistoryEventListener(
|
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
private val loginHistoryRepository: LoginHistoryRepository,
|
|
||||||
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
|
|
||||||
private var batchSize: Int = 0
|
|
||||||
|
|
||||||
@Async
|
|
||||||
@EventListener(classes = [LoginHistoryEvent::class])
|
|
||||||
fun onLoginCompleted(event: LoginHistoryEvent) {
|
|
||||||
log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
|
|
||||||
|
|
||||||
queue.add(event.toEntity(idGenerator.create())).also {
|
|
||||||
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queue.size >= batchSize) {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
|
|
||||||
fun flushScheduled() {
|
|
||||||
log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
|
||||||
|
|
||||||
if (queue.isEmpty()) {
|
|
||||||
log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
fun flushAll() {
|
|
||||||
log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
|
|
||||||
while (!queue.isEmpty()) {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flush() {
|
|
||||||
log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
|
||||||
|
|
||||||
if (queue.isEmpty()) {
|
|
||||||
log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val batch = mutableListOf<LoginHistoryEntity>()
|
|
||||||
repeat(batchSize) {
|
|
||||||
val entity: LoginHistoryEntity? = queue.poll()
|
|
||||||
|
|
||||||
if (entity != null) {
|
|
||||||
batch.add(entity)
|
|
||||||
} else {
|
|
||||||
return@repeat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batch.isEmpty()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loginHistoryRepository.saveAll(batch).also {
|
|
||||||
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
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
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class LoginHistoryService(
|
||||||
|
private val loginHistoryRepository: LoginHistoryRepository,
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
) {
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun createSuccessHistory(
|
||||||
|
principalId: Long,
|
||||||
|
principalType: PrincipalType,
|
||||||
|
context: LoginContext
|
||||||
|
) {
|
||||||
|
createHistory(principalId, principalType, success = true, context = context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun createFailureHistory(
|
||||||
|
principalId: Long,
|
||||||
|
principalType: PrincipalType,
|
||||||
|
context: LoginContext
|
||||||
|
) {
|
||||||
|
createHistory(principalId, principalType, success = false, context = context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHistory(
|
||||||
|
principalId: Long,
|
||||||
|
principalType: PrincipalType,
|
||||||
|
success: Boolean,
|
||||||
|
context: LoginContext
|
||||||
|
) {
|
||||||
|
log.info { "[createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
LoginHistoryEntity(
|
||||||
|
id = idGenerator.create(),
|
||||||
|
principalId = principalId,
|
||||||
|
principalType = principalType,
|
||||||
|
success = success,
|
||||||
|
ipAddress = context.ipAddress,
|
||||||
|
userAgent = context.userAgent,
|
||||||
|
).also {
|
||||||
|
loginHistoryRepository.save(it)
|
||||||
|
log.info { "[createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
log.warn { "[createHistory] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth.business.domain
|
|
||||||
|
|
||||||
class LoginHistoryEvent(
|
|
||||||
val id: Long,
|
|
||||||
val type: PrincipalType,
|
|
||||||
var success: Boolean = true,
|
|
||||||
val ipAddress: String,
|
|
||||||
val userAgent: String
|
|
||||||
) {
|
|
||||||
fun onSuccess(): LoginHistoryEvent {
|
|
||||||
this.success = true
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onFailure(): LoginHistoryEvent {
|
|
||||||
this.success = false
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -50,7 +50,7 @@ class JwtUtils(
|
|||||||
val claims = extractAllClaims(token)
|
val claims = extractAllClaims(token)
|
||||||
|
|
||||||
return claims.subject ?: run {
|
return claims.subject ?: run {
|
||||||
log.debug { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
||||||
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth.mapper
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
|
||||||
|
|
||||||
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
|
|
||||||
id = id,
|
|
||||||
principalId = this.id,
|
|
||||||
principalType = this.type,
|
|
||||||
success = this.success,
|
|
||||||
ipAddress = this.ipAddress,
|
|
||||||
userAgent = this.userAgent
|
|
||||||
)
|
|
||||||
@ -1,12 +1,11 @@
|
|||||||
package com.sangdol.roomescape.auth.web.support.resolver
|
package com.sangdol.roomescape.auth.web.support.resolver
|
||||||
|
|
||||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.auth.exception.AuthException
|
import com.sangdol.roomescape.auth.exception.AuthException
|
||||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
import com.sangdol.roomescape.auth.web.support.accessToken
|
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.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@ -22,6 +21,7 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
@Component
|
@Component
|
||||||
class UserContextResolver(
|
class UserContextResolver(
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
|
private val userService: UserService,
|
||||||
) : HandlerMethodArgumentResolver {
|
) : HandlerMethodArgumentResolver {
|
||||||
|
|
||||||
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||||
@ -38,11 +38,9 @@ class UserContextResolver(
|
|||||||
val token: String? = request.accessToken()
|
val token: String? = request.accessToken()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val id: Long = jwtUtils.extractSubject(token).also {
|
val id: Long = jwtUtils.extractSubject(token).toLong()
|
||||||
MdcPrincipalIdUtil.set(it)
|
|
||||||
}.toLong()
|
|
||||||
|
|
||||||
return CurrentUserContext(id = id)
|
return userService.findContextById(id)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
|
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
|
||||||
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import org.springframework.stereotype.Component
|
|||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Profile("!deploy & local")
|
@Profile("local")
|
||||||
class LocalDatabaseCleaner(
|
class LocalDatabaseCleaner(
|
||||||
private val jdbcTemplate: JdbcTemplate
|
private val jdbcTemplate: JdbcTemplate
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.sangdol.roomescape.common.types
|
package com.sangdol.roomescape.common.types
|
||||||
|
|
||||||
data class CurrentUserContext(
|
data class CurrentUserContext(
|
||||||
val id: Long
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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] 작업 저장 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,69 +1,130 @@
|
|||||||
package com.sangdol.roomescape.order.business
|
package com.sangdol.roomescape.order.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
import com.sangdol.common.persistence.TransactionExecutionUtil
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
import com.sangdol.common.types.exception.ErrorCode
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
import com.sangdol.common.types.exception.RoomescapeException
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
import com.sangdol.roomescape.order.exception.OrderException
|
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.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
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.ReservationService
|
||||||
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
|
||||||
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class OrderService(
|
class OrderService(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
private val reservationService: ReservationService,
|
private val reservationService: ReservationService,
|
||||||
private val scheduleService: ScheduleService,
|
private val scheduleService: ScheduleService,
|
||||||
private val paymentService: PaymentService,
|
private val paymentService: PaymentService,
|
||||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
private val orderValidator: OrderValidator,
|
private val orderValidator: OrderValidator,
|
||||||
private val eventPublisher: ApplicationEventPublisher
|
private val paymentAttemptRepository: PaymentAttemptRepository,
|
||||||
|
private val orderPostProcessorService: OrderPostProcessorService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
||||||
|
val trial = paymentAttemptRepository.countByReservationId(reservationId)
|
||||||
val paymentKey = paymentConfirmRequest.paymentKey
|
val paymentKey = paymentConfirmRequest.paymentKey
|
||||||
|
|
||||||
log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
validateCanConfirm(reservationId)
|
validateAndMarkInProgress(reservationId)
|
||||||
reservationService.markInProgress(reservationId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
|
val paymentClientResponse: PaymentGatewayResponse =
|
||||||
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
|
requestConfirmPayment(reservationId, paymentConfirmRequest)
|
||||||
|
|
||||||
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
||||||
e.errorCode
|
e.errorCode
|
||||||
} else {
|
} 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) {
|
private fun validateAndMarkInProgress(reservationId: Long) {
|
||||||
log.debug { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
||||||
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
||||||
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
orderValidator.validateCanConfirm(reservation, schedule)
|
orderValidator.validateCanConfirm(reservation, schedule)
|
||||||
|
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" }
|
||||||
} catch (e: OrderException) {
|
} catch (e: OrderException) {
|
||||||
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
||||||
throw OrderException(errorCode, e.message)
|
throw OrderException(errorCode, e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reservationService.markInProgress(reservationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.sangdol.roomescape.order.business
|
|||||||
import com.sangdol.common.utils.KoreaDateTime
|
import com.sangdol.common.utils.KoreaDateTime
|
||||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
import com.sangdol.roomescape.order.exception.OrderException
|
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.dto.ReservationStateResponse
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
@ -15,11 +16,18 @@ import java.time.LocalDateTime
|
|||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class OrderValidator {
|
class OrderValidator(
|
||||||
|
private val paymentAttemptRepository: PaymentAttemptRepository
|
||||||
|
) {
|
||||||
fun validateCanConfirm(
|
fun validateCanConfirm(
|
||||||
reservation: ReservationStateResponse,
|
reservation: ReservationStateResponse,
|
||||||
schedule: ScheduleStateResponse
|
schedule: ScheduleStateResponse
|
||||||
) {
|
) {
|
||||||
|
if (paymentAttemptRepository.isSuccessAttemptExists(reservation.id)) {
|
||||||
|
log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservation.id}" }
|
||||||
|
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
validateReservationStatus(reservation)
|
validateReservationStatus(reservation)
|
||||||
validateScheduleStatus(schedule)
|
validateScheduleStatus(schedule)
|
||||||
}
|
}
|
||||||
@ -27,12 +35,15 @@ class OrderValidator {
|
|||||||
private fun validateReservationStatus(reservation: ReservationStateResponse) {
|
private fun validateReservationStatus(reservation: ReservationStateResponse) {
|
||||||
when (reservation.status) {
|
when (reservation.status) {
|
||||||
ReservationStatus.CONFIRMED -> {
|
ReservationStatus.CONFIRMED -> {
|
||||||
throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED)
|
log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" }
|
||||||
|
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||||
}
|
}
|
||||||
ReservationStatus.EXPIRED -> {
|
ReservationStatus.EXPIRED -> {
|
||||||
|
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
|
||||||
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||||
}
|
}
|
||||||
ReservationStatus.CANCELED -> {
|
ReservationStatus.CANCELED -> {
|
||||||
|
log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" }
|
||||||
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
|
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
@ -41,14 +52,14 @@ class OrderValidator {
|
|||||||
|
|
||||||
private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
|
private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
|
||||||
if (schedule.status != ScheduleStatus.HOLD) {
|
if (schedule.status != ScheduleStatus.HOLD) {
|
||||||
log.debug { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
|
log.info { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
|
||||||
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||||
}
|
}
|
||||||
|
|
||||||
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
||||||
val nowDateTime = KoreaDateTime.now()
|
val nowDateTime = KoreaDateTime.now()
|
||||||
if (scheduleDateTime.isBefore(nowDateTime)) {
|
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)
|
throw OrderException(OrderErrorCode.PAST_SCHEDULE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,11 @@ enum class OrderErrorCode(
|
|||||||
override val message: String
|
override val message: String
|
||||||
) : ErrorCode {
|
) : ErrorCode {
|
||||||
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
||||||
ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
||||||
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||||
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||||
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
||||||
|
|
||||||
ORDER_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,4 +6,11 @@ import com.sangdol.common.types.exception.RoomescapeException
|
|||||||
class OrderException(
|
class OrderException(
|
||||||
override val errorCode: ErrorCode,
|
override val errorCode: ErrorCode,
|
||||||
override val message: String = errorCode.message,
|
override val message: String = errorCode.message,
|
||||||
|
var trial: Long = 0
|
||||||
) : RoomescapeException(errorCode, message)
|
) : RoomescapeException(errorCode, message)
|
||||||
|
|
||||||
|
class OrderErrorResponse(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val trial: Long
|
||||||
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.sangdol.roomescape.order.infrastructure.persistence
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface PostOrderTaskRepository : JpaRepository<PostOrderTaskEntity, Long> {
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
package com.sangdol.roomescape.payment.business
|
package com.sangdol.roomescape.payment.business
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.common.persistence.TransactionExecutionUtil
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
|
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.dto.*
|
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.exception.PaymentException
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
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 com.sangdol.roomescape.payment.mapper.toResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@ -22,20 +18,18 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class PaymentService(
|
class PaymentService(
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
private val paymentClient: TosspayClient,
|
private val paymentClient: TosspayClient,
|
||||||
private val paymentRepository: PaymentRepository,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
private val paymentDetailRepository: PaymentDetailRepository,
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository,
|
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||||
|
private val paymentWriter: PaymentWriter,
|
||||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
private val eventPublisher: ApplicationEventPublisher
|
|
||||||
) {
|
) {
|
||||||
fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
|
fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse {
|
||||||
log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
||||||
try {
|
try {
|
||||||
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
|
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) {
|
} catch (e: Exception) {
|
||||||
when(e) {
|
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) {
|
fun cancel(userId: Long, request: PaymentCancelRequest) {
|
||||||
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
|
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
|
||||||
|
|
||||||
@ -72,17 +79,12 @@ class PaymentService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
|
paymentWriter.cancel(
|
||||||
|
userId = userId,
|
||||||
clientCancelResponse.cancels.toEntity(
|
payment = payment,
|
||||||
id = idGenerator.create(),
|
requestedAt = request.requestedAt,
|
||||||
paymentId = payment.id,
|
cancelResponse = clientCancelResponse
|
||||||
cancelRequestedAt = request.requestedAt,
|
)
|
||||||
canceledBy = userId
|
|
||||||
).also {
|
|
||||||
canceledPaymentRepository.save(it)
|
|
||||||
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
|
||||||
}
|
|
||||||
}.also {
|
}.also {
|
||||||
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
||||||
}
|
}
|
||||||
@ -90,7 +92,7 @@ class PaymentService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
|
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
|
||||||
log.debug { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
|
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
|
||||||
|
|
||||||
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
|
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
|
||||||
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
|
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
|
||||||
@ -99,13 +101,11 @@ class PaymentService(
|
|||||||
return payment?.toResponse(
|
return payment?.toResponse(
|
||||||
detail = paymentDetail?.toResponse(),
|
detail = paymentDetail?.toResponse(),
|
||||||
cancel = cancelDetail?.toResponse()
|
cancel = cancelDetail?.toResponse()
|
||||||
).also {
|
)
|
||||||
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
|
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
|
||||||
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||||
|
|
||||||
return paymentRepository.findByReservationId(reservationId)
|
return paymentRepository.findByReservationId(reservationId)
|
||||||
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
|
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
|
||||||
@ -116,7 +116,7 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
|
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
|
||||||
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||||
|
|
||||||
return paymentRepository.findByReservationId(reservationId)
|
return paymentRepository.findByReservationId(reservationId)
|
||||||
.also {
|
.also {
|
||||||
@ -129,7 +129,7 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
|
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
|
||||||
log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
|
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
|
||||||
|
|
||||||
return paymentDetailRepository.findByPaymentId(paymentId).also {
|
return paymentDetailRepository.findByPaymentId(paymentId).also {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
@ -141,7 +141,7 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
|
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
|
||||||
log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
|
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
|
||||||
|
|
||||||
return canceledPaymentRepository.findByPaymentId(paymentId).also {
|
return canceledPaymentRepository.findByPaymentId(paymentId).also {
|
||||||
if (it == null) {
|
if (it == null) {
|
||||||
|
|||||||
@ -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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,8 +2,11 @@ package com.sangdol.roomescape.payment.docs
|
|||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
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.common.types.CurrentUserContext
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
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.Operation
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
@ -13,6 +16,13 @@ import org.springframework.web.bind.annotation.RequestBody
|
|||||||
|
|
||||||
interface PaymentAPI {
|
interface PaymentAPI {
|
||||||
|
|
||||||
|
@UserOnly
|
||||||
|
@Operation(summary = "결제 승인")
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
|
fun confirmPayment(
|
||||||
|
@Valid @RequestBody request: PaymentConfirmRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>>
|
||||||
|
|
||||||
@Operation(summary = "결제 취소")
|
@Operation(summary = "결제 취소")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun cancelPayment(
|
fun cancelPayment(
|
||||||
|
|||||||
@ -8,6 +8,11 @@ data class PaymentConfirmRequest(
|
|||||||
val amount: Int,
|
val amount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PaymentCreateResponse(
|
||||||
|
val paymentId: Long,
|
||||||
|
val detailId: Long
|
||||||
|
)
|
||||||
|
|
||||||
data class PaymentCancelRequest(
|
data class PaymentCancelRequest(
|
||||||
val reservationId: Long,
|
val reservationId: Long,
|
||||||
val cancelReason: String,
|
val cancelReason: String,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ class TosspayClient(
|
|||||||
amount: Int,
|
amount: Int,
|
||||||
): PaymentGatewayResponse {
|
): PaymentGatewayResponse {
|
||||||
val startTime = System.currentTimeMillis()
|
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)
|
return confirmClient.request(paymentKey, orderId, amount)
|
||||||
.also {
|
.also {
|
||||||
@ -47,7 +47,7 @@ class TosspayClient(
|
|||||||
cancelReason: String
|
cancelReason: String
|
||||||
): PaymentGatewayCancelResponse {
|
): PaymentGatewayCancelResponse {
|
||||||
val startTime = System.currentTimeMillis()
|
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 {
|
return cancelClient.request(paymentKey, amount, cancelReason).also {
|
||||||
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
|
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
|
||||||
|
|||||||
@ -1,14 +1,82 @@
|
|||||||
package com.sangdol.roomescape.payment.mapper
|
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.CancelDetail
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
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
|
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(
|
fun CancelDetail.toEntity(
|
||||||
id: Long,
|
id: Long,
|
||||||
paymentId: Long,
|
paymentId: Long,
|
||||||
@ -26,88 +94,3 @@ fun CancelDetail.toEntity(
|
|||||||
transferDiscountAmount = this.transferDiscountAmount,
|
transferDiscountAmount = this.transferDiscountAmount,
|
||||||
easypayDiscountAmount = this.easyPayDiscountAmount
|
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,18 +6,27 @@ import com.sangdol.roomescape.common.types.CurrentUserContext
|
|||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.docs.PaymentAPI
|
import com.sangdol.roomescape.payment.docs.PaymentAPI
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
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 jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/payments")
|
@RequestMapping("/payments")
|
||||||
class PaymentController(
|
class PaymentController(
|
||||||
private val paymentService: PaymentService
|
private val paymentService: PaymentService
|
||||||
) : PaymentAPI {
|
) : 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")
|
@PostMapping("/cancel")
|
||||||
override fun cancelPayment(
|
override fun cancelPayment(
|
||||||
@User user: CurrentUserContext,
|
@User user: CurrentUserContext,
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class RegionService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun readAllSido(): SidoListResponse {
|
fun readAllSido(): SidoListResponse {
|
||||||
log.debug { "[readAllSido] 모든 시/도 조회 시작" }
|
log.info { "[readAllSido] 모든 시/도 조회 시작" }
|
||||||
val result: List<Pair<String, String>> = regionRepository.readAllSido()
|
val result: List<Pair<String, String>> = regionRepository.readAllSido()
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
if (result.isEmpty()) {
|
||||||
@ -32,7 +32,7 @@ class RegionService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findSigunguBySido(sidoCode: String): SigunguListResponse {
|
fun findSigunguBySido(sidoCode: String): SigunguListResponse {
|
||||||
log.debug { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
|
log.info { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
|
||||||
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
|
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
if (result.isEmpty()) {
|
||||||
@ -47,7 +47,7 @@ class RegionService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse {
|
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 {
|
return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let {
|
||||||
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||||
@ -60,7 +60,7 @@ class RegionService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findRegionInfo(regionCode: String): RegionInfoResponse {
|
fun findRegionInfo(regionCode: String): RegionInfoResponse {
|
||||||
log.debug { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
|
log.info { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
|
||||||
|
|
||||||
return regionRepository.findByCode(regionCode)?.let {
|
return regionRepository.findByCode(regionCode)?.let {
|
||||||
log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }
|
log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class ReservationService(
|
|||||||
user: CurrentUserContext,
|
user: CurrentUserContext,
|
||||||
request: PendingReservationCreateRequest
|
request: PendingReservationCreateRequest
|
||||||
): PendingReservationCreateResponse {
|
): PendingReservationCreateResponse {
|
||||||
log.debug { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
|
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
|
||||||
|
|
||||||
run {
|
run {
|
||||||
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
|
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
|
||||||
@ -64,7 +64,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun confirmReservation(id: Long) {
|
fun confirmReservation(id: Long) {
|
||||||
log.debug { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
|
log.info { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
|
||||||
val reservation: ReservationEntity = findOrThrow(id)
|
val reservation: ReservationEntity = findOrThrow(id)
|
||||||
|
|
||||||
run {
|
run {
|
||||||
@ -81,7 +81,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
|
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)
|
val reservation: ReservationEntity = findOrThrow(reservationId)
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
|
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
|
||||||
log.debug { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
|
log.info { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
|
||||||
|
|
||||||
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
|
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
|
||||||
userId = user.id,
|
userId = user.id,
|
||||||
@ -125,7 +125,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findDetailById(id: Long): ReservationAdditionalResponse {
|
fun findDetailById(id: Long): ReservationAdditionalResponse {
|
||||||
log.debug { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
|
log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
|
||||||
|
|
||||||
val reservation: ReservationEntity = findOrThrow(id)
|
val reservation: ReservationEntity = findOrThrow(id)
|
||||||
val user: UserContactResponse = userService.findContactById(reservation.userId)
|
val user: UserContactResponse = userService.findContactById(reservation.userId)
|
||||||
@ -141,7 +141,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findStatusWithLock(id: Long): ReservationStateResponse {
|
fun findStatusWithLock(id: Long): ReservationStateResponse {
|
||||||
log.debug { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
|
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
|
||||||
|
|
||||||
return reservationRepository.findByIdForUpdate(id)?.let {
|
return reservationRepository.findByIdForUpdate(id)?.let {
|
||||||
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" }
|
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" }
|
||||||
@ -154,7 +154,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun markInProgress(reservationId: Long) {
|
fun markInProgress(reservationId: Long) {
|
||||||
log.debug { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
|
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
|
||||||
|
|
||||||
findOrThrow(reservationId).apply {
|
findOrThrow(reservationId).apply {
|
||||||
this.status = ReservationStatus.PAYMENT_IN_PROGRESS
|
this.status = ReservationStatus.PAYMENT_IN_PROGRESS
|
||||||
@ -164,7 +164,7 @@ class ReservationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findOrThrow(id: Long): ReservationEntity {
|
private fun findOrThrow(id: Long): ReservationEntity {
|
||||||
log.debug { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
||||||
|
|
||||||
return reservationRepository.findByIdOrNull(id)
|
return reservationRepository.findByIdOrNull(id)
|
||||||
?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }
|
?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
package com.sangdol.roomescape.reservation.business
|
package com.sangdol.roomescape.reservation.business
|
||||||
|
|
||||||
import com.sangdol.common.utils.KoreaDateTime
|
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.ReservationErrorCode
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationException
|
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.dto.ScheduleStateResponse
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
import java.time.Instant
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
package com.sangdol.roomescape.reservation.business.event
|
|
||||||
|
|
||||||
class ReservationConfirmEvent(
|
|
||||||
val reservationId: Long
|
|
||||||
)
|
|
||||||
@ -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] 예약 확정 이벤트 처리 완료" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,6 +5,7 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleReposi
|
|||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@ -14,6 +15,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
@EnableScheduling
|
||||||
class IncompletedReservationScheduler(
|
class IncompletedReservationScheduler(
|
||||||
private val scheduleRepository: ScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val reservationRepository: ReservationRepository
|
private val reservationRepository: ReservationRepository
|
||||||
@ -22,10 +24,10 @@ class IncompletedReservationScheduler(
|
|||||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||||
@Transactional
|
@Transactional
|
||||||
fun processExpiredHoldSchedule() {
|
fun processExpiredHoldSchedule() {
|
||||||
log.debug { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
||||||
|
|
||||||
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
|
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
|
||||||
log.debug { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
|
log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleRepository.releaseHeldSchedules(targets).also {
|
scheduleRepository.releaseHeldSchedules(targets).also {
|
||||||
@ -36,7 +38,7 @@ class IncompletedReservationScheduler(
|
|||||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||||
@Transactional
|
@Transactional
|
||||||
fun processExpiredReservation() {
|
fun processExpiredReservation() {
|
||||||
log.debug { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
|
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
|
||||||
|
|
||||||
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
|
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
|
||||||
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
|
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
|
||||||
|
|||||||
@ -16,8 +16,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
|
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
|
||||||
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
|
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
@Query("""
|
||||||
SELECT
|
SELECT
|
||||||
r.id
|
r.id
|
||||||
FROM
|
FROM
|
||||||
@ -27,8 +27,7 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
WHERE
|
WHERE
|
||||||
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
""", nativeQuery = true
|
""", nativeQuery = true)
|
||||||
)
|
|
||||||
fun findAllExpiredReservation(): List<Long>
|
fun findAllExpiredReservation(): List<Long>
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@ -48,23 +47,4 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
""", nativeQuery = true
|
""", nativeQuery = true
|
||||||
)
|
)
|
||||||
fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class AdminScheduleService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
|
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()
|
val searchDate = date ?: KoreaDate.today()
|
||||||
|
|
||||||
@ -44,12 +44,14 @@ class AdminScheduleService(
|
|||||||
.sortedBy { it.time }
|
.sortedBy { it.time }
|
||||||
|
|
||||||
return schedules.toAdminSummaryResponse()
|
return schedules.toAdminSummaryResponse()
|
||||||
.also { log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } }
|
.also {
|
||||||
|
log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findScheduleAudit(id: Long): AuditingInfo {
|
fun findScheduleAudit(id: Long): AuditingInfo {
|
||||||
log.debug { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
|
log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
|
||||||
|
|
||||||
val schedule: ScheduleEntity = findOrThrow(id)
|
val schedule: ScheduleEntity = findOrThrow(id)
|
||||||
|
|
||||||
@ -62,7 +64,7 @@ class AdminScheduleService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
|
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)
|
scheduleValidator.validateCanCreate(storeId, request)
|
||||||
|
|
||||||
@ -77,12 +79,14 @@ class AdminScheduleService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ScheduleCreateResponse(schedule.id)
|
return ScheduleCreateResponse(schedule.id)
|
||||||
.also { log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" } }
|
.also {
|
||||||
|
log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
|
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
|
||||||
log.debug { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
|
log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
|
||||||
|
|
||||||
if (request.isAllParamsNull()) {
|
if (request.isAllParamsNull()) {
|
||||||
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
|
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
|
||||||
@ -100,7 +104,7 @@ class AdminScheduleService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteSchedule(id: Long) {
|
fun deleteSchedule(id: Long) {
|
||||||
log.debug { "[deleteSchedule] 일정 삭제 시작: id=$id" }
|
log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" }
|
||||||
|
|
||||||
val schedule: ScheduleEntity = findOrThrow(id).also {
|
val schedule: ScheduleEntity = findOrThrow(id).also {
|
||||||
scheduleValidator.validateCanDelete(it)
|
scheduleValidator.validateCanDelete(it)
|
||||||
@ -112,7 +116,7 @@ class AdminScheduleService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findOrThrow(id: Long): ScheduleEntity {
|
private fun findOrThrow(id: Long): ScheduleEntity {
|
||||||
log.debug { "[findOrThrow] 일정 조회 시작: id=$id" }
|
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
|
||||||
|
|
||||||
return scheduleRepository.findByIdOrNull(id)
|
return scheduleRepository.findByIdOrNull(id)
|
||||||
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
|
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
|
||||||
|
|||||||
@ -30,12 +30,13 @@ class ScheduleService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
|
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 currentDate: LocalDate = KoreaDate.today()
|
||||||
val currentTime: LocalTime = KoreaTime.now()
|
val currentTime: LocalTime = KoreaTime.now()
|
||||||
|
|
||||||
if (date.isBefore(currentDate)) {
|
if (date.isBefore(currentDate)) {
|
||||||
|
log.warn { "[getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" }
|
||||||
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +44,6 @@ class ScheduleService(
|
|||||||
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
|
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
|
||||||
.filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
|
.filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
|
||||||
|
|
||||||
|
|
||||||
return schedules.toResponseWithTheme()
|
return schedules.toResponseWithTheme()
|
||||||
.also {
|
.also {
|
||||||
log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date 인 ${it.schedules.size}개 일정 조회 완료" }
|
log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date 인 ${it.schedules.size}개 일정 조회 완료" }
|
||||||
@ -52,7 +52,7 @@ class ScheduleService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun holdSchedule(id: Long) {
|
fun holdSchedule(id: Long) {
|
||||||
log.debug { "[holdSchedule] 일정 Holding 시작: id=$id" }
|
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
|
||||||
|
|
||||||
val schedule = findForUpdateOrThrow(id).also {
|
val schedule = findForUpdateOrThrow(id).also {
|
||||||
scheduleValidator.validateCanHold(it)
|
scheduleValidator.validateCanHold(it)
|
||||||
@ -69,7 +69,7 @@ class ScheduleService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findStateWithLock(id: Long): ScheduleStateResponse {
|
fun findStateWithLock(id: Long): ScheduleStateResponse {
|
||||||
log.debug { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
|
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
|
||||||
|
|
||||||
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
|
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
|
||||||
?: run {
|
?: run {
|
||||||
@ -95,7 +95,7 @@ class ScheduleService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) {
|
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 {
|
scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also {
|
||||||
log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
|
log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
|
||||||
@ -103,7 +103,7 @@ class ScheduleService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
|
private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
|
||||||
log.debug { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
|
log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
|
||||||
|
|
||||||
return scheduleRepository.findByIdForUpdate(id)
|
return scheduleRepository.findByIdForUpdate(id)
|
||||||
?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }
|
?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }
|
||||||
|
|||||||
@ -59,7 +59,7 @@ class ScheduleValidator(
|
|||||||
|
|
||||||
private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
|
private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
|
||||||
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
|
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
|
||||||
log.debug {
|
log.info {
|
||||||
"[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
|
"[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
|
||||||
}
|
}
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
|
||||||
@ -71,7 +71,7 @@ class ScheduleValidator(
|
|||||||
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
|
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
|
||||||
|
|
||||||
if (inputDateTime.isBefore(now)) {
|
if (inputDateTime.isBefore(now)) {
|
||||||
log.debug {
|
log.info {
|
||||||
"[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
|
"[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
|
||||||
}
|
}
|
||||||
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
||||||
@ -82,7 +82,7 @@ class ScheduleValidator(
|
|||||||
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
|
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
|
||||||
.firstOrNull { it.containsTime(time) }
|
.firstOrNull { it.containsTime(time) }
|
||||||
?.let {
|
?.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)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,14 +133,14 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
s.id
|
s.id
|
||||||
FROM
|
FROM
|
||||||
schedule s
|
schedule s
|
||||||
LEFT JOIN
|
|
||||||
reservation r
|
|
||||||
ON
|
|
||||||
r.schedule_id = s.id AND r.status IN ('PENDING', 'PAYMENT_IN_PROGRESS')
|
|
||||||
WHERE
|
WHERE
|
||||||
s.status = 'HOLD'
|
s.status = 'HOLD'
|
||||||
AND s.hold_expired_at <= :now
|
AND s.hold_expired_at <= :now
|
||||||
AND r.id IS NULL
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM reservation r
|
||||||
|
WHERE r.schedule_id = s.id AND (r.status = 'PENDING' OR r.status = 'PAYMENT_IN_PROGRESS')
|
||||||
|
)
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
""", nativeQuery = true
|
""", nativeQuery = true
|
||||||
)
|
)
|
||||||
|
|||||||
@ -35,7 +35,7 @@ class StoreService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getDetail(id: Long): StoreDetailResponse {
|
fun getDetail(id: Long): StoreDetailResponse {
|
||||||
log.debug { "[getDetail] 매장 상세 조회 시작: id=${id}" }
|
log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" }
|
||||||
|
|
||||||
val store: StoreEntity = findOrThrow(id)
|
val store: StoreEntity = findOrThrow(id)
|
||||||
val region = regionService.findRegionInfo(store.regionCode)
|
val region = regionService.findRegionInfo(store.regionCode)
|
||||||
@ -47,7 +47,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun register(request: StoreRegisterRequest): StoreRegisterResponse {
|
fun register(request: StoreRegisterRequest): StoreRegisterResponse {
|
||||||
log.debug { "[register] 매장 등록 시작: name=${request.name}" }
|
log.info { "[register] 매장 등록 시작: name=${request.name}" }
|
||||||
|
|
||||||
storeValidator.validateCanRegister(request)
|
storeValidator.validateCanRegister(request)
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun update(id: Long, request: StoreUpdateRequest) {
|
fun update(id: Long, request: StoreUpdateRequest) {
|
||||||
log.debug { "[update] 매장 수정 시작: id=${id}, request=${request}" }
|
log.info { "[update] 매장 수정 시작: id=${id}, request=${request}" }
|
||||||
|
|
||||||
storeValidator.validateCanUpdate(request)
|
storeValidator.validateCanUpdate(request)
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun disableById(id: Long) {
|
fun disableById(id: Long) {
|
||||||
log.debug { "[inactive] 매장 비활성화 시작: id=${id}" }
|
log.info { "[inactive] 매장 비활성화 시작: id=${id}" }
|
||||||
|
|
||||||
findOrThrow(id).apply {
|
findOrThrow(id).apply {
|
||||||
this.disable()
|
this.disable()
|
||||||
@ -94,7 +94,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse {
|
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse {
|
||||||
log.debug { "[getAllActiveStores] 전체 매장 조회 시작" }
|
log.info { "[getAllActiveStores] 전체 매장 조회 시작" }
|
||||||
|
|
||||||
val regionCode: String? = when {
|
val regionCode: String? = when {
|
||||||
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
|
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
|
||||||
@ -108,7 +108,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findStoreInfo(id: Long): StoreInfoResponse {
|
fun findStoreInfo(id: Long): StoreInfoResponse {
|
||||||
log.debug { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
|
log.info { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
val store: StoreEntity = findOrThrow(id)
|
val store: StoreEntity = findOrThrow(id)
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ class StoreService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getAuditInfo(store: StoreEntity): AuditingInfo {
|
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 createdBy = adminService.findOperatorOrUnknown(store.createdBy)
|
||||||
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
|
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ class StoreService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findOrThrow(id: Long): StoreEntity {
|
private fun findOrThrow(id: Long): StoreEntity {
|
||||||
log.debug { "[findOrThrow] 매장 조회 시작: id=${id}" }
|
log.info { "[findOrThrow] 매장 조회 시작: id=${id}" }
|
||||||
|
|
||||||
return storeRepository.findActiveStoreById(id)
|
return storeRepository.findActiveStoreById(id)
|
||||||
?.also {
|
?.also {
|
||||||
|
|||||||
@ -31,21 +31,21 @@ class StoreValidator(
|
|||||||
|
|
||||||
private fun validateDuplicateNameExist(name: String) {
|
private fun validateDuplicateNameExist(name: String) {
|
||||||
if (storeRepository.existsByName(name)) {
|
if (storeRepository.existsByName(name)) {
|
||||||
log.debug { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
|
log.info { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
|
||||||
throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED)
|
throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateDuplicateContactExist(contact: String) {
|
private fun validateDuplicateContactExist(contact: String) {
|
||||||
if (storeRepository.existsByContact(contact)) {
|
if (storeRepository.existsByContact(contact)) {
|
||||||
log.debug { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
|
log.info { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
|
||||||
throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED)
|
throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateDuplicateAddressExist(address: String) {
|
private fun validateDuplicateAddressExist(address: String) {
|
||||||
if (storeRepository.existsByAddress(address)) {
|
if (storeRepository.existsByAddress(address)) {
|
||||||
log.debug { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
|
log.info { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
|
||||||
throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED)
|
throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
package com.sangdol.roomescape.test
|
|
||||||
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/tests")
|
|
||||||
class TestSetupController(
|
|
||||||
private val testSetupService: TestSetupService
|
|
||||||
) {
|
|
||||||
|
|
||||||
@GetMapping("/themes")
|
|
||||||
fun findAllThemeWithTimes(): ThemeWithTimesList {
|
|
||||||
return testSetupService.findAllThemeWithTimes()
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/max-iterations")
|
|
||||||
fun maxIterations(): MaxIterations {
|
|
||||||
return testSetupService.calculateMaxIterations()
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/admin/accounts")
|
|
||||||
fun findAllStoreAdminAccounts(): AccountList {
|
|
||||||
return testSetupService.findAllStoreAdminAccounts()
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/schedules/available")
|
|
||||||
fun findAllAvailableSchedules(): ScheduleWithThemeIdList {
|
|
||||||
return testSetupService.findAllAvailableSchedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/users")
|
|
||||||
fun findAllUsers(
|
|
||||||
@RequestParam("count") count: Long
|
|
||||||
): AccountList {
|
|
||||||
return testSetupService.findAllUserAccounts(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/stores")
|
|
||||||
fun findAllStoreIds(): StoreIdList {
|
|
||||||
return testSetupService.findAllStores()
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/reservations-with-user")
|
|
||||||
fun findAllReservationsWithUser(): ReservationWithUserList {
|
|
||||||
return testSetupService.findAllReservationWithUser()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package com.sangdol.roomescape.test
|
|
||||||
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
data class ThemeWithTimesList(
|
|
||||||
val results: List<ThemeWithTimes>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ThemeWithTimes(
|
|
||||||
val id: Long,
|
|
||||||
val times: List<ScheduleTime>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleTime(
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AccountList(
|
|
||||||
val results: List<Account>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Account(
|
|
||||||
val account: String,
|
|
||||||
val password: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleWithThemeIdList(
|
|
||||||
val results: List<ScheduleWithThemeId>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleWithThemeId(
|
|
||||||
val scheduleId: Long,
|
|
||||||
val themeId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MaxIterations(
|
|
||||||
val count: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class StoreIdList(
|
|
||||||
val results: List<StoreId>
|
|
||||||
)
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
@ -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>
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
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.store.infrastructure.persistence.StoreRepository
|
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
@Service
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAllThemeWithTimes(): ThemeWithTimesList {
|
|
||||||
val storeOpenTime = LocalTime.of(10, 0)
|
|
||||||
val storeCloseTime = LocalTime.of(22, 0)
|
|
||||||
val timeGapMinutes = 10L
|
|
||||||
|
|
||||||
return ThemeWithTimesList(themeRepository.findAll()
|
|
||||||
.filter { it.isActive }
|
|
||||||
.map { theme ->
|
|
||||||
val times: MutableList<ScheduleTime> = mutableListOf()
|
|
||||||
var startTime: LocalTime = storeOpenTime
|
|
||||||
|
|
||||||
while (startTime.isBefore(storeCloseTime)) {
|
|
||||||
val themeAvailableMinute = theme.availableMinutes.toLong()
|
|
||||||
val endTime: LocalTime = startTime.plusMinutes(themeAvailableMinute)
|
|
||||||
|
|
||||||
if (endTime.isAfter(storeCloseTime)) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
times.add(ScheduleTime(startTime, endTime))
|
|
||||||
startTime = endTime.plusMinutes(timeGapMinutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeWithTimes(theme.id, times)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun calculateMaxIterations(): MaxIterations {
|
|
||||||
val max = findAllThemeWithTimes().results.sumOf { it.times.size }
|
|
||||||
val stores = storeRepository.findAll().size
|
|
||||||
val days = 6
|
|
||||||
|
|
||||||
return MaxIterations((max * stores * days).toLong())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAllStoreAdminAccounts(): AccountList {
|
|
||||||
return AccountList(adminRepository.findAll()
|
|
||||||
.filter { it.permissionLevel == AdminPermissionLevel.FULL_ACCESS }
|
|
||||||
.filter { it.type == AdminType.STORE }
|
|
||||||
.map { Account(it.account, it.password) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAllUserAccounts(count: Long): AccountList {
|
|
||||||
return AccountList(userRepository.findUsersByCount(count)
|
|
||||||
.map { Account(it.email, it.password) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAllAvailableSchedule(): ScheduleWithThemeIdList {
|
|
||||||
return ScheduleWithThemeIdList(scheduleRepository.findAllAvailableSchedules())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAllStores(): StoreIdList {
|
|
||||||
return StoreIdList(storeRepository.findAll().map {
|
|
||||||
StoreId(it.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAllReservationWithUser(): ReservationWithUserList {
|
|
||||||
return ReservationWithUserList(
|
|
||||||
reservationRepository.findAllReservationWithUser()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,18 +3,22 @@ package com.sangdol.roomescape.theme.business
|
|||||||
import com.sangdol.common.persistence.IDGenerator
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
import com.sangdol.roomescape.admin.business.AdminService
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
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.ThemeErrorCode
|
||||||
import com.sangdol.roomescape.theme.exception.ThemeException
|
import com.sangdol.roomescape.theme.exception.ThemeException
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import com.sangdol.roomescape.theme.mapper.toDetailResponse
|
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.toEntity
|
||||||
import com.sangdol.roomescape.theme.mapper.toNameListResponse
|
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.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.cache.annotation.CacheEvict
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@ -30,7 +34,7 @@ class AdminThemeService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findThemeSummaries(): ThemeSummaryListResponse {
|
fun findThemeSummaries(): ThemeSummaryListResponse {
|
||||||
log.debug { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
|
log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
|
||||||
|
|
||||||
return themeRepository.findAll()
|
return themeRepository.findAll()
|
||||||
.toSummaryListResponse()
|
.toSummaryListResponse()
|
||||||
@ -39,7 +43,7 @@ class AdminThemeService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findThemeDetail(id: Long): ThemeDetailResponse {
|
fun findThemeDetail(id: Long): ThemeDetailResponse {
|
||||||
log.debug { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
|
log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
val theme: ThemeEntity = findOrThrow(id)
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
@ -53,7 +57,7 @@ class AdminThemeService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findActiveThemes(): ThemeNameListResponse {
|
fun findActiveThemes(): ThemeNameListResponse {
|
||||||
log.debug { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
|
log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
|
||||||
|
|
||||||
return themeRepository.findActiveThemes()
|
return themeRepository.findActiveThemes()
|
||||||
.toNameListResponse()
|
.toNameListResponse()
|
||||||
@ -65,7 +69,7 @@ class AdminThemeService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
|
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
|
||||||
log.debug { "[createTheme] 테마 생성 시작: name=${request.name}" }
|
log.info { "[createTheme] 테마 생성 시작: name=${request.name}" }
|
||||||
|
|
||||||
themeValidator.validateCanCreate(request)
|
themeValidator.validateCanCreate(request)
|
||||||
|
|
||||||
@ -77,10 +81,10 @@ class AdminThemeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteTheme(id: Long) {
|
fun deleteTheme(id: Long) {
|
||||||
log.debug { "[deleteTheme] 테마 삭제 시작: id=${id}" }
|
log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" }
|
||||||
|
|
||||||
val theme: ThemeEntity = findOrThrow(id)
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
@ -89,10 +93,9 @@ class AdminThemeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
|
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
|
||||||
log.debug { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
|
log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
|
||||||
|
|
||||||
if (request.isAllParamsNull()) {
|
if (request.isAllParamsNull()) {
|
||||||
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
|
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
|
||||||
@ -121,7 +124,7 @@ class AdminThemeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findOrThrow(id: Long): ThemeEntity {
|
private fun findOrThrow(id: Long): ThemeEntity {
|
||||||
log.debug { "[findOrThrow] 테마 조회 시작: id=$id" }
|
log.info { "[findOrThrow] 테마 조회 시작: id=$id" }
|
||||||
|
|
||||||
return themeRepository.findByIdOrNull(id)
|
return themeRepository.findByIdOrNull(id)
|
||||||
?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }
|
?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }
|
||||||
|
|||||||
@ -10,8 +10,6 @@ import com.sangdol.roomescape.theme.mapper.toInfoResponse
|
|||||||
import com.sangdol.roomescape.theme.mapper.toListResponse
|
import com.sangdol.roomescape.theme.mapper.toListResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
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.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@ -23,19 +21,13 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ThemeService(
|
class ThemeService(
|
||||||
private val themeRepository: ThemeRepository,
|
private val themeRepository: ThemeRepository
|
||||||
meterRegistry: MeterRegistry
|
|
||||||
) {
|
) {
|
||||||
private val themeDetailQueryRequestCount = meterRegistry.counter("theme.detail.query.requested")
|
|
||||||
|
|
||||||
@Cacheable(cacheNames = ["theme-details"], key="#id")
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findInfoById(id: Long): ThemeInfoResponse {
|
fun findInfoById(id: Long): ThemeInfoResponse {
|
||||||
log.debug { "[findInfoById] 테마 조회 시작: id=$id" }
|
log.info { "[findInfoById] 테마 조회 시작: id=$id" }
|
||||||
|
|
||||||
val theme = themeRepository.findByIdOrNull(id)?.also {
|
val theme = themeRepository.findByIdOrNull(id) ?: run {
|
||||||
themeDetailQueryRequestCount.increment()
|
|
||||||
} ?: run {
|
|
||||||
log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
|
log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
|
||||||
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||||
}
|
}
|
||||||
@ -46,7 +38,7 @@ class ThemeService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
|
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
|
||||||
log.debug { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
|
log.info { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
|
||||||
|
|
||||||
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(KoreaDate.today())
|
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(KoreaDate.today())
|
||||||
val previousWeekSaturday = previousWeekSunday.plusDays(6)
|
val previousWeekSaturday = previousWeekSunday.plusDays(6)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.sangdol.roomescape.user.business
|
package com.sangdol.roomescape.user.business
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
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.UserContactResponse
|
||||||
import com.sangdol.roomescape.user.dto.UserCreateRequest
|
import com.sangdol.roomescape.user.dto.UserCreateRequest
|
||||||
import com.sangdol.roomescape.user.dto.UserCreateResponse
|
import com.sangdol.roomescape.user.dto.UserCreateResponse
|
||||||
@ -27,9 +28,20 @@ class UserService(
|
|||||||
private val userValidator: UserValidator,
|
private val userValidator: UserValidator,
|
||||||
private val idGenerator: IDGenerator
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun findCredentialsByAccount(email: String): UserLoginCredentials {
|
fun findCredentialsByAccount(email: String): UserLoginCredentials {
|
||||||
log.debug { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
|
log.info { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
|
||||||
|
|
||||||
return userRepository.findByEmail(email)
|
return userRepository.findByEmail(email)
|
||||||
?.let {
|
?.let {
|
||||||
@ -37,13 +49,14 @@ class UserService(
|
|||||||
it.toCredentials()
|
it.toCredentials()
|
||||||
}
|
}
|
||||||
?: run {
|
?: run {
|
||||||
|
log.info { "[findCredentialsByAccount] 회원 조회 실패" }
|
||||||
throw UserException(UserErrorCode.USER_NOT_FOUND)
|
throw UserException(UserErrorCode.USER_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findContactById(id: Long): UserContactResponse {
|
fun findContactById(id: Long): UserContactResponse {
|
||||||
log.debug { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
|
log.info { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
val user = findOrThrow(id)
|
val user = findOrThrow(id)
|
||||||
|
|
||||||
@ -55,7 +68,7 @@ class UserService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun signup(request: UserCreateRequest): UserCreateResponse {
|
fun signup(request: UserCreateRequest): UserCreateResponse {
|
||||||
log.debug { "[signup] 회원가입 시작: request:$request" }
|
log.info { "[signup] 회원가입 시작: request:$request" }
|
||||||
|
|
||||||
userValidator.validateCanSignup(request.email, request.phone)
|
userValidator.validateCanSignup(request.email, request.phone)
|
||||||
|
|
||||||
|
|||||||
@ -15,14 +15,14 @@ class UserValidator(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
fun validateCanSignup(email: String, phone: String) {
|
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)) {
|
if (userRepository.existsByEmail(email)) {
|
||||||
log.debug { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
|
log.info { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
|
||||||
throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS)
|
throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS)
|
||||||
}
|
}
|
||||||
if (userRepository.existsByPhone(phone)) {
|
if (userRepository.existsByPhone(phone)) {
|
||||||
log.debug { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
|
log.info { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
|
||||||
throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS)
|
throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,14 +11,6 @@ spring:
|
|||||||
active: ${ACTIVE_PROFILE:local}
|
active: ${ACTIVE_PROFILE:local}
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: false
|
open-in-view: false
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
jdbc:
|
|
||||||
batch_size: ${JDBC_BATCH_SIZE:100}
|
|
||||||
order_inserts: true
|
|
||||||
cache:
|
|
||||||
type: caffeine
|
|
||||||
cache-names: ${CACHE_NAMES:theme-details}
|
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
@ -31,7 +23,7 @@ management:
|
|||||||
show-details: always
|
show-details: always
|
||||||
|
|
||||||
payment:
|
payment:
|
||||||
api-base-url: ${PAYMENT_SERVER_ENDPOINT:http://localhost:8000}
|
api-base-url: ${PAYMENT_SERVER_ENDPOINT:https://api.tosspayments.com}
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
|
|||||||
236
service/src/main/resources/schema/schema-h2.sql
Normal file
236
service/src/main/resources/schema/schema-h2.sql
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
create table if not exists region (
|
||||||
|
code varchar(10) primary key,
|
||||||
|
sido_code varchar(2) not null,
|
||||||
|
sigungu_code varchar(3) not null,
|
||||||
|
sido_name varchar(20) not null,
|
||||||
|
sigungu_name varchar(20) not null,
|
||||||
|
|
||||||
|
constraint uk_region__sido_sigungu_code unique (sido_code, sigungu_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists store(
|
||||||
|
id bigint primary key,
|
||||||
|
name varchar(20) not null,
|
||||||
|
address varchar(100) not null,
|
||||||
|
contact varchar(50) not null,
|
||||||
|
business_reg_num varchar(12) not null,
|
||||||
|
region_code varchar(10) not null,
|
||||||
|
status varchar(20) not null,
|
||||||
|
|
||||||
|
created_at timestamp not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at timestamp not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
|
||||||
|
constraint uk_store__name unique (name),
|
||||||
|
constraint uk_store__contact unique (contact),
|
||||||
|
constraint uk_store__address unique (address),
|
||||||
|
constraint uk_store__business_reg_num unique (business_reg_num),
|
||||||
|
constraint fk_store__region_code foreign key (region_code) references region (code)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists users(
|
||||||
|
id bigint primary key,
|
||||||
|
name varchar(50) not null,
|
||||||
|
email varchar(255) not null,
|
||||||
|
password varchar(255) not null,
|
||||||
|
phone varchar(20) not null,
|
||||||
|
region_code varchar(10) null,
|
||||||
|
status varchar(20) not null,
|
||||||
|
created_at datetime(6) not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at datetime(6) not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
|
||||||
|
constraint uk__users_email unique (email),
|
||||||
|
constraint uk__users_phone unique (phone),
|
||||||
|
constraint fk__users_region_code foreign key (region_code) references region (code)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_status_history(
|
||||||
|
id bigint primary key,
|
||||||
|
user_id bigint not null,
|
||||||
|
status varchar(20) not null,
|
||||||
|
reason varchar(255) not null,
|
||||||
|
created_at datetime(6) not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at datetime(6) not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
|
||||||
|
constraint fk__user_status_history_user_id foreign key (user_id) references users (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists admin(
|
||||||
|
id bigint primary key,
|
||||||
|
account varchar(20) not null,
|
||||||
|
password varchar(255) not null,
|
||||||
|
name varchar(20) not null,
|
||||||
|
phone varchar(20) not null,
|
||||||
|
type varchar(20) not null,
|
||||||
|
store_id bigint,
|
||||||
|
permission_level varchar(20) not null,
|
||||||
|
created_at timestamp not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at timestamp not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
|
||||||
|
constraint uk_admin__account unique (account),
|
||||||
|
constraint uk_admin__phone unique (phone),
|
||||||
|
constraint chk_admin__type check (type in ('HQ', 'STORE')),
|
||||||
|
constraint chk_admin__store_id check (
|
||||||
|
(type = 'HQ' AND store_id IS NULL) OR
|
||||||
|
(type = 'STORE' AND store_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
constraint fk_admin__store_id foreign key (store_id) references store (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists login_history(
|
||||||
|
id bigint primary key,
|
||||||
|
principal_id bigint not null,
|
||||||
|
principal_type varchar(20) not null,
|
||||||
|
success boolean not null,
|
||||||
|
ip_address varchar(45) not null,
|
||||||
|
user_agent varchar(255) not null,
|
||||||
|
created_at timestamp not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists theme (
|
||||||
|
id bigint primary key ,
|
||||||
|
name varchar(30) not null,
|
||||||
|
difficulty varchar(20) not null,
|
||||||
|
description varchar(255) not null,
|
||||||
|
thumbnail_url varchar(255) not null,
|
||||||
|
price int not null,
|
||||||
|
min_participants smallint not null,
|
||||||
|
max_participants smallint not null,
|
||||||
|
available_minutes smallint not null,
|
||||||
|
expected_minutes_from smallint not null,
|
||||||
|
expected_minutes_to smallint not null,
|
||||||
|
is_active boolean not null,
|
||||||
|
created_at timestamp not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at timestamp not null,
|
||||||
|
updated_by bigint not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists schedule (
|
||||||
|
id bigint primary key,
|
||||||
|
date date not null,
|
||||||
|
time time not null,
|
||||||
|
store_id bigint not null,
|
||||||
|
theme_id bigint not null,
|
||||||
|
status varchar(30) not null,
|
||||||
|
created_at timestamp not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at timestamp not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
hold_expired_at timestamp null,
|
||||||
|
|
||||||
|
constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id),
|
||||||
|
constraint fk_schedule__store_id foreign key (store_id) references store (id),
|
||||||
|
constraint fk_schedule__theme_id foreign key (theme_id) references theme (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists reservation (
|
||||||
|
id bigint primary key,
|
||||||
|
user_id bigint not null,
|
||||||
|
schedule_id bigint not null,
|
||||||
|
reserver_name varchar(30) not null,
|
||||||
|
reserver_contact varchar(30) not null,
|
||||||
|
participant_count smallint not null,
|
||||||
|
requirement varchar(255) not null,
|
||||||
|
status varchar(30) not null,
|
||||||
|
created_at timestamp not null,
|
||||||
|
created_by bigint not null,
|
||||||
|
updated_at timestamp not null,
|
||||||
|
updated_by bigint not null,
|
||||||
|
|
||||||
|
constraint fk_reservation__user_id foreign key (user_id) references users (id),
|
||||||
|
constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists canceled_reservation (
|
||||||
|
id bigint primary key,
|
||||||
|
reservation_id bigint not null,
|
||||||
|
canceled_by bigint not null,
|
||||||
|
cancel_reason varchar(50) not null,
|
||||||
|
canceled_at timestamp not null,
|
||||||
|
status varchar(30) not null,
|
||||||
|
|
||||||
|
constraint uk_canceled_reservations__reservation_id unique (reservation_id),
|
||||||
|
constraint fk_canceled_reservations__reservation_id foreign key (reservation_id) references reservation (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists payment (
|
||||||
|
id bigint primary key,
|
||||||
|
reservation_id bigint not null,
|
||||||
|
type varchar(20) not null,
|
||||||
|
method varchar(30) not null,
|
||||||
|
payment_key varchar(255) not null unique,
|
||||||
|
order_id varchar(255) not null unique,
|
||||||
|
total_amount integer not null,
|
||||||
|
status varchar(20) not null,
|
||||||
|
requested_at timestamp not null,
|
||||||
|
approved_at timestamp not null,
|
||||||
|
|
||||||
|
constraint uk_payment__reservationId unique (reservation_id),
|
||||||
|
constraint fk_payment__reservationId foreign key (reservation_id) references reservation (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists payment_detail(
|
||||||
|
id bigint primary key,
|
||||||
|
payment_id bigint not null unique,
|
||||||
|
supplied_amount integer not null,
|
||||||
|
vat integer not null,
|
||||||
|
|
||||||
|
constraint fk_payment_detail__paymentId foreign key (payment_id) references payment (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists payment_bank_transfer_detail (
|
||||||
|
id bigint primary key,
|
||||||
|
bank_code varchar(20) not null,
|
||||||
|
settlement_status varchar(20) not null,
|
||||||
|
|
||||||
|
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists payment_card_detail (
|
||||||
|
id bigint primary key,
|
||||||
|
issuer_code varchar(20) not null,
|
||||||
|
card_type varchar(10) not null,
|
||||||
|
owner_type varchar(10) not null,
|
||||||
|
amount integer not null,
|
||||||
|
card_number varchar(20) not null,
|
||||||
|
approval_number varchar(8) not null, -- 실제로는 unique 이지만 테스트 결제 위젯에서는 항상 000000으로 동일한 값이 나옴.
|
||||||
|
installment_plan_months tinyint not null,
|
||||||
|
is_interest_free boolean not null,
|
||||||
|
easypay_provider_code varchar(20),
|
||||||
|
easypay_discount_amount integer,
|
||||||
|
|
||||||
|
constraint fk_payment_card_detail__id foreign key (id) references payment_detail (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists payment_easypay_prepaid_detail(
|
||||||
|
id bigint primary key,
|
||||||
|
easypay_provider_code varchar(20) not null,
|
||||||
|
amount integer not null,
|
||||||
|
discount_amount integer not null,
|
||||||
|
|
||||||
|
constraint fk_payment_easypay_prepaid_detail__id foreign key (id) references payment_detail (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists canceled_payment(
|
||||||
|
id bigint primary key,
|
||||||
|
payment_id bigint not null,
|
||||||
|
requested_at timestamp not null,
|
||||||
|
canceled_at timestamp not null,
|
||||||
|
canceled_by bigint not null,
|
||||||
|
cancel_reason varchar(255) not null,
|
||||||
|
cancel_amount integer not null,
|
||||||
|
card_discount_amount integer not null,
|
||||||
|
transfer_discount_amount integer not null,
|
||||||
|
easypay_discount_amount integer not null,
|
||||||
|
|
||||||
|
constraint uk_canceled_payment__paymentId unique (payment_id),
|
||||||
|
constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment(id)
|
||||||
|
);
|
||||||
@ -1,18 +1,16 @@
|
|||||||
package com.sangdol.roomescape.auth
|
package com.sangdol.roomescape.auth
|
||||||
|
|
||||||
import com.ninjasquad.springmockk.MockkBean
|
|
||||||
import com.ninjasquad.springmockk.SpykBean
|
import com.ninjasquad.springmockk.SpykBean
|
||||||
import com.sangdol.common.types.web.HttpStatus
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
||||||
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
|
||||||
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
|
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
|
||||||
import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY
|
import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY
|
||||||
import com.sangdol.roomescape.auth.business.LoginHistoryEventListener
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
|
||||||
import com.sangdol.roomescape.auth.dto.LoginRequest
|
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
import com.sangdol.roomescape.supports.AdminFixture
|
import com.sangdol.roomescape.supports.AdminFixture
|
||||||
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
||||||
import com.sangdol.roomescape.supports.UserFixture
|
import com.sangdol.roomescape.supports.UserFixture
|
||||||
@ -20,31 +18,19 @@ import com.sangdol.roomescape.supports.runTest
|
|||||||
import com.sangdol.roomescape.user.exception.UserErrorCode
|
import com.sangdol.roomescape.user.exception.UserErrorCode
|
||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.shouldNotBe
|
import io.kotest.matchers.shouldNotBe
|
||||||
import io.mockk.*
|
import io.mockk.every
|
||||||
import io.restassured.response.ValidatableResponse
|
import io.restassured.response.ValidatableResponse
|
||||||
import org.hamcrest.CoreMatchers.equalTo
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
|
|
||||||
class AuthApiTest(
|
class AuthApiTest(
|
||||||
@SpykBean private val jwtUtils: JwtUtils,
|
@SpykBean private val jwtUtils: JwtUtils,
|
||||||
@MockkBean(relaxed = true) private val loginHistoryEventListener: LoginHistoryEventListener,
|
private val loginHistoryRepository: LoginHistoryRepository
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
lateinit var slot: CapturingSlot<LoginHistoryEvent>
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
beforeTest {
|
|
||||||
slot = slot<LoginHistoryEvent>()
|
|
||||||
every {
|
|
||||||
loginHistoryEventListener.onLoginCompleted(capture(slot))
|
|
||||||
} just Runs
|
|
||||||
}
|
|
||||||
|
|
||||||
afterTest {
|
|
||||||
clearMocks(jwtUtils, loginHistoryEventListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
context("로그인을 시도한다.") {
|
context("로그인을 시도한다.") {
|
||||||
context("성공 응답") {
|
context("성공 응답") {
|
||||||
listOf(
|
listOf(
|
||||||
@ -78,7 +64,6 @@ class AuthApiTest(
|
|||||||
password = user.password,
|
password = user.password,
|
||||||
type = PrincipalType.USER,
|
type = PrincipalType.USER,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val token: String = it.extract().path("data.accessToken")
|
val token: String = it.extract().path("data.accessToken")
|
||||||
jwtUtils.extractSubject(token) shouldBe user.id.toString()
|
jwtUtils.extractSubject(token) shouldBe user.id.toString()
|
||||||
}
|
}
|
||||||
@ -86,16 +71,6 @@ class AuthApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
context("실패 응답") {
|
context("실패 응답") {
|
||||||
|
|
||||||
lateinit var slot: CapturingSlot<LoginHistoryEvent>
|
|
||||||
|
|
||||||
beforeTest {
|
|
||||||
slot = slot<LoginHistoryEvent>()
|
|
||||||
every {
|
|
||||||
loginHistoryEventListener.onLoginCompleted(capture(slot))
|
|
||||||
} just Runs
|
|
||||||
}
|
|
||||||
|
|
||||||
context("계정이 맞으면 로그인 실패 이력을 남긴다.") {
|
context("계정이 맞으면 로그인 실패 이력을 남긴다.") {
|
||||||
test("비밀번호가 틀린 경우") {
|
test("비밀번호가 틀린 경우") {
|
||||||
val admin = testAuthUtil.createAdmin(AdminFixture.default)
|
val admin = testAuthUtil.createAdmin(AdminFixture.default)
|
||||||
@ -113,14 +88,9 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode))
|
body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
verify(exactly = 1) {
|
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) {
|
||||||
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
|
||||||
}
|
|
||||||
|
|
||||||
assertSoftly(slot.captured) {
|
|
||||||
this.id shouldBe admin.id
|
|
||||||
this.type shouldBe PrincipalType.ADMIN
|
|
||||||
this.success shouldBe false
|
this.success shouldBe false
|
||||||
|
this.principalType shouldBe PrincipalType.ADMIN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,14 +115,9 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode))
|
body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
verify(exactly = 1) {
|
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) {
|
||||||
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
|
||||||
}
|
|
||||||
|
|
||||||
assertSoftly(slot.captured) {
|
|
||||||
this.id shouldBe admin.id
|
|
||||||
this.type shouldBe PrincipalType.ADMIN
|
|
||||||
this.success shouldBe false
|
this.success shouldBe false
|
||||||
|
this.principalType shouldBe PrincipalType.ADMIN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -179,9 +144,7 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode))
|
body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
verify(exactly = 0) {
|
loginHistoryRepository.findAll() shouldHaveSize 0
|
||||||
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,9 +168,7 @@ class AuthApiTest(
|
|||||||
body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode))
|
body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode))
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
verify(exactly = 0) {
|
loginHistoryRepository.findAll() shouldHaveSize 0
|
||||||
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,13 +198,10 @@ class AuthApiTest(
|
|||||||
).also {
|
).also {
|
||||||
extraAssertions?.invoke(it)
|
extraAssertions?.invoke(it)
|
||||||
|
|
||||||
verify(exactly = 1) {
|
assertSoftly(loginHistoryRepository.findByPrincipalId(id)) { history ->
|
||||||
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
|
history shouldHaveSize (1)
|
||||||
}
|
history[0].success shouldBe true
|
||||||
assertSoftly(slot.captured) {
|
history[0].principalType shouldBe type
|
||||||
this.id shouldBe id
|
|
||||||
this.type shouldBe type
|
|
||||||
this.success shouldBe true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
package com.sangdol.roomescape.auth
|
||||||
|
|
||||||
|
import com.ninjasquad.springmockk.MockkBean
|
||||||
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
import com.sangdol.roomescape.supports.AdminFixture
|
||||||
|
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
||||||
|
import com.sangdol.roomescape.supports.UserFixture
|
||||||
|
import com.sangdol.roomescape.supports.runTest
|
||||||
|
import io.mockk.clearMocks
|
||||||
|
import io.mockk.every
|
||||||
|
|
||||||
|
class FailOnSaveLoginHistoryTest(
|
||||||
|
@MockkBean private val loginHistoryRepository: LoginHistoryRepository
|
||||||
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
context("로그인 이력 저장 과정에서 예외가 발생해도 로그인 작업 자체는 정상 처리된다.") {
|
||||||
|
beforeTest {
|
||||||
|
clearMocks(loginHistoryRepository)
|
||||||
|
|
||||||
|
every {
|
||||||
|
loginHistoryRepository.save(any())
|
||||||
|
} throws RuntimeException("intended exception")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("회원") {
|
||||||
|
val user = testAuthUtil.signup(UserFixture.createRequest)
|
||||||
|
val request = LoginRequest(user.email, user.password, PrincipalType.USER)
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/auth/login")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("관리자") {
|
||||||
|
val admin = testAuthUtil.createAdmin(AdminFixture.default)
|
||||||
|
val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN)
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/auth/login")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,71 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
|
||||||
import com.sangdol.roomescape.supports.IDGenerator
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.collections.shouldBeEmpty
|
|
||||||
import io.kotest.matchers.collections.shouldNotContainAnyOf
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
|
|
||||||
class LoginHistoryEventListenerTest : FunSpec() {
|
|
||||||
|
|
||||||
val histories: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue((1..1000).map {
|
|
||||||
LoginHistoryEntity(
|
|
||||||
id = IDGenerator.create(),
|
|
||||||
principalId = IDGenerator.create(),
|
|
||||||
principalType = PrincipalType.USER,
|
|
||||||
success = true,
|
|
||||||
ipAddress = "127.0.0.1",
|
|
||||||
userAgent = "UserAgent"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
init {
|
|
||||||
test("ConcurrentLinkedQueue에서 데이터를 꺼내는 작업을 여러 스레드에서 동시 호출해도 중복 처리되지 않는다.") {
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
val latch = CountDownLatch(2)
|
|
||||||
|
|
||||||
val flushJob1 = async {
|
|
||||||
latch.countDown()
|
|
||||||
latch.await()
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
val flushJob2 = async {
|
|
||||||
latch.countDown()
|
|
||||||
latch.await()
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
val flushJob1Result = flushJob1.await()
|
|
||||||
val flushJob2Result = flushJob2.await()
|
|
||||||
|
|
||||||
flushJob2Result shouldNotContainAnyOf flushJob1Result
|
|
||||||
flushJob1Result shouldNotContainAnyOf flushJob2Result
|
|
||||||
(flushJob1Result.size + flushJob2Result.size) shouldBe 1000
|
|
||||||
histories.shouldBeEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flush(batchSize: Int = 500): MutableList<LoginHistoryEntity> {
|
|
||||||
val batch = mutableListOf<LoginHistoryEntity>()
|
|
||||||
repeat(batchSize) {
|
|
||||||
val entity: LoginHistoryEntity? = histories.poll()
|
|
||||||
|
|
||||||
if (entity != null) {
|
|
||||||
batch.add(entity)
|
|
||||||
} else {
|
|
||||||
return@repeat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return batch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +1,46 @@
|
|||||||
package com.sangdol.roomescape.order
|
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.KoreaDate
|
||||||
import com.sangdol.common.utils.KoreaTime
|
import com.sangdol.common.utils.KoreaTime
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
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.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.PaymentConfirmRequest
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
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.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
||||||
import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
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.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.supports.*
|
import com.sangdol.roomescape.supports.*
|
||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
|
import io.kotest.matchers.booleans.shouldBeTrue
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.mockk.*
|
import io.mockk.every
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
class OrderApiTest(
|
class OrderApiTest(
|
||||||
@MockkBean(relaxed = true) private val paymentClient: TosspayClient,
|
@SpykBean private val paymentService: PaymentService,
|
||||||
@MockkBean(relaxed = true) private val reservationEventListener: ReservationEventListener,
|
private val paymentAttemptRepository: PaymentAttemptRepository,
|
||||||
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
|
|
||||||
private val reservationRepository: ReservationRepository,
|
private val reservationRepository: ReservationRepository,
|
||||||
|
private val postOrderTaskRepository: PostOrderTaskRepository,
|
||||||
|
private val scheduleRepository: ScheduleRepository,
|
||||||
|
private val paymentRepository: PaymentRepository,
|
||||||
|
private val paymentDetailRepository: PaymentDetailRepository
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
|
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
|
||||||
@ -74,52 +82,39 @@ class OrderApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
val reservationId = dummyInitializer.createPendingReservation(user).id
|
val reservation = dummyInitializer.createPendingReservation(user)
|
||||||
|
|
||||||
val reservationConfirmEventSlot = slot<ReservationConfirmEvent>()
|
|
||||||
val paymentEventSlot = slot<PaymentEvent>()
|
|
||||||
|
|
||||||
every {
|
every {
|
||||||
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
|
paymentService.requestConfirm(paymentRequest)
|
||||||
} returns expectedPaymentResponse
|
} returns expectedPaymentResponse
|
||||||
|
|
||||||
every {
|
|
||||||
paymentEventListener.handlePaymentEvent(capture(paymentEventSlot))
|
|
||||||
} just runs
|
|
||||||
|
|
||||||
every {
|
|
||||||
reservationEventListener.handleReservationConfirmEvent(capture(reservationConfirmEventSlot))
|
|
||||||
} just runs
|
|
||||||
|
|
||||||
runTest(
|
runTest(
|
||||||
token = token,
|
token = token,
|
||||||
using = {
|
using = {
|
||||||
body(paymentRequest)
|
body(paymentRequest)
|
||||||
},
|
},
|
||||||
on = {
|
on = {
|
||||||
post("/orders/${reservationId}/confirm")
|
post("/orders/${reservation.id}/confirm")
|
||||||
},
|
},
|
||||||
expect = {
|
expect = {
|
||||||
statusCode(HttpStatus.OK.value())
|
statusCode(HttpStatus.OK.value())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
verify(exactly = 1) {
|
assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) {
|
||||||
paymentEventListener.handlePaymentEvent(any())
|
this.status shouldBe ScheduleStatus.RESERVED
|
||||||
}.also {
|
this.holdExpiredAt shouldBe null
|
||||||
assertSoftly(paymentEventSlot.captured) {
|
|
||||||
this.paymentKey shouldBe expectedPaymentResponse.paymentKey
|
|
||||||
this.reservationId shouldBe reservationId
|
|
||||||
}
|
}
|
||||||
|
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) {
|
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue()
|
||||||
reservationEventListener.handleReservationConfirmEvent(any())
|
|
||||||
}.also {
|
|
||||||
assertSoftly(reservationConfirmEventSlot.captured) {
|
|
||||||
this.reservationId shouldBe reservationId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context("검증 과정에서의 실패 응답") {
|
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("이미 확정된 예약이면 실패한다.") {
|
test("이미 확정된 예약이면 실패한다.") {
|
||||||
val reservation = dummyInitializer.createConfirmReservation(user)
|
val reservation = dummyInitializer.createConfirmReservation(user)
|
||||||
|
|
||||||
@ -210,23 +223,68 @@ class OrderApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
context("결제 과정에서의 실패 응답.") {
|
context("결제 과정에서의 실패 응답.") {
|
||||||
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태가 된다.") {
|
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") {
|
||||||
val reservationId = dummyInitializer.createPendingReservation(user).id
|
val reservation = dummyInitializer.createPendingReservation(user)
|
||||||
|
|
||||||
every {
|
every {
|
||||||
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
|
paymentService.requestConfirm(paymentRequest)
|
||||||
} throws ExternalPaymentException(400, "INVALID_REQUEST", "잘못 요청함")
|
} throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR)
|
||||||
|
|
||||||
runExceptionTest(
|
runExceptionTest(
|
||||||
token = token,
|
token = token,
|
||||||
method = HttpMethod.POST,
|
method = HttpMethod.POST,
|
||||||
endpoint = "/orders/${reservationId}/confirm",
|
endpoint = "/orders/${reservation.id}/confirm",
|
||||||
requestBody = paymentRequest,
|
requestBody = paymentRequest,
|
||||||
expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
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)!!) {
|
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue()
|
||||||
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
|
|
||||||
|
val postOrderTask = postOrderTaskRepository.findAll().first { it.reservationId == reservation.id }
|
||||||
|
assertSoftly(postOrderTask) {
|
||||||
|
it.shouldNotBeNull()
|
||||||
|
it.paymentKey shouldBe paymentRequest.paymentKey
|
||||||
|
it.trial shouldBe 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import com.sangdol.roomescape.supports.ReservationFixture
|
|||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.shouldNotBe
|
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@ -70,7 +69,7 @@ class OrderConcurrencyTest(
|
|||||||
|
|
||||||
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
|
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
|
paymentService.requestConfirm(paymentConfirmRequest)
|
||||||
} returns paymentGatewayResponse
|
} returns paymentGatewayResponse
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@ -88,13 +87,18 @@ class OrderConcurrencyTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
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("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
|
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
|
paymentService.requestConfirm(paymentConfirmRequest)
|
||||||
} returns paymentGatewayResponse
|
} returns paymentGatewayResponse
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@ -104,11 +108,11 @@ class OrderConcurrencyTest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(10)
|
|
||||||
|
|
||||||
async {
|
async {
|
||||||
assertThrows<OrderException> {
|
assertThrows<OrderException> {
|
||||||
orderService.confirm(reservation.id, paymentConfirmRequest)
|
orderService.confirm(reservation.id, paymentConfirmRequest)
|
||||||
|
}.also {
|
||||||
|
it.trial shouldBe 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,21 +4,22 @@ import com.ninjasquad.springmockk.MockkBean
|
|||||||
import com.sangdol.common.types.web.HttpStatus
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
import com.sangdol.roomescape.payment.business.domain.*
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
import com.sangdol.roomescape.payment.dto.*
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
|
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.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.payment.mapper.toDetailEntity
|
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
|
||||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
import com.sangdol.roomescape.supports.PaymentFixture
|
||||||
import com.sangdol.roomescape.payment.mapper.toEvent
|
import com.sangdol.roomescape.supports.runExceptionTest
|
||||||
import com.sangdol.roomescape.supports.*
|
import com.sangdol.roomescape.supports.runTest
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import org.hamcrest.CoreMatchers.containsString
|
||||||
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
|
|
||||||
@ -27,10 +28,211 @@ class PaymentAPITest(
|
|||||||
private val tosspayClient: TosspayClient,
|
private val tosspayClient: TosspayClient,
|
||||||
private val paymentService: PaymentService,
|
private val paymentService: PaymentService,
|
||||||
private val paymentRepository: PaymentRepository,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository
|
private val canceledPaymentRepository: CanceledPaymentRepository
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
init {
|
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("결제를 취소한다.") {
|
||||||
context("권한이 없으면 접근할 수 없다.") {
|
context("권한이 없으면 접근할 수 없다.") {
|
||||||
val endpoint = "/payments/cancel"
|
val endpoint = "/payments/cancel"
|
||||||
@ -43,6 +245,16 @@ class PaymentAPITest(
|
|||||||
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
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("정상 취소") {
|
test("정상 취소") {
|
||||||
@ -50,7 +262,7 @@ class PaymentAPITest(
|
|||||||
val reservation = dummyInitializer.createConfirmReservation(user = user)
|
val reservation = dummyInitializer.createConfirmReservation(user = user)
|
||||||
val confirmRequest = PaymentFixture.confirmRequest
|
val confirmRequest = PaymentFixture.confirmRequest
|
||||||
|
|
||||||
val paymentEntity = createPayment(
|
val paymentCreateResponse = createPayment(
|
||||||
request = confirmRequest,
|
request = confirmRequest,
|
||||||
reservationId = reservation.id
|
reservationId = reservation.id
|
||||||
)
|
)
|
||||||
@ -77,10 +289,10 @@ class PaymentAPITest(
|
|||||||
statusCode(HttpStatus.OK.value())
|
statusCode(HttpStatus.OK.value())
|
||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
val payment = paymentRepository.findByIdOrNull(paymentEntity.id)
|
val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId)
|
||||||
?: throw AssertionError("Unexpected Exception Occurred.")
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
val canceledPayment =
|
val canceledPayment =
|
||||||
canceledPaymentRepository.findByPaymentId(paymentEntity.id)
|
canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId)
|
||||||
?: throw AssertionError("Unexpected Exception Occurred.")
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
|
|
||||||
payment.status shouldBe PaymentStatus.CANCELED
|
payment.status shouldBe PaymentStatus.CANCELED
|
||||||
@ -107,7 +319,7 @@ class PaymentAPITest(
|
|||||||
private fun createPayment(
|
private fun createPayment(
|
||||||
request: PaymentConfirmRequest,
|
request: PaymentConfirmRequest,
|
||||||
reservationId: Long,
|
reservationId: Long,
|
||||||
): PaymentEntity {
|
): PaymentCreateResponse {
|
||||||
every {
|
every {
|
||||||
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
|
||||||
} returns PaymentFixture.confirmResponse(
|
} returns PaymentFixture.confirmResponse(
|
||||||
@ -119,10 +331,49 @@ class PaymentAPITest(
|
|||||||
transferDetail = null,
|
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())
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -49,6 +49,15 @@ class ReservationApiTest(
|
|||||||
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
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
|
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("관리자") {
|
||||||
|
runExceptionTest(
|
||||||
|
token = testAuthUtil.defaultHqAdminLogin().second,
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
endpoint = endpoint,
|
||||||
|
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
@ -220,6 +238,15 @@ class ReservationApiTest(
|
|||||||
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("관리자") {
|
||||||
|
runExceptionTest(
|
||||||
|
token = testAuthUtil.defaultHqAdminLogin().second,
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
endpoint = endpoint,
|
||||||
|
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
@ -288,6 +315,15 @@ class ReservationApiTest(
|
|||||||
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("관리자") {
|
||||||
|
runExceptionTest(
|
||||||
|
token = testAuthUtil.defaultHqAdminLogin().second,
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
endpoint = endpoint,
|
||||||
|
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
@ -341,6 +377,15 @@ class ReservationApiTest(
|
|||||||
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("관리자") {
|
||||||
|
runExceptionTest(
|
||||||
|
token = testAuthUtil.defaultHqAdminLogin().second,
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
endpoint = endpoint,
|
||||||
|
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context("정상 응답") {
|
context("정상 응답") {
|
||||||
|
|||||||
@ -108,7 +108,7 @@ class ReservationConcurrencyTest(
|
|||||||
|
|
||||||
private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long {
|
private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long {
|
||||||
return reservationService.createPendingReservation(
|
return reservationService.createPendingReservation(
|
||||||
user = CurrentUserContext(id = user.id),
|
user = CurrentUserContext(id = user.id, name = user.name),
|
||||||
request = PendingReservationCreateRequest(
|
request = PendingReservationCreateRequest(
|
||||||
scheduleId = schedule.id,
|
scheduleId = schedule.id,
|
||||||
reserverName = user.name,
|
reserverName = user.name,
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
package com.sangdol.roomescape.supports
|
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.business.domain.PaymentMethod
|
||||||
import com.sangdol.roomescape.payment.dto.*
|
import com.sangdol.roomescape.payment.dto.*
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
||||||
import com.sangdol.roomescape.payment.mapper.toDetailEntity
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.payment.mapper.toEvent
|
|
||||||
import com.sangdol.roomescape.payment.mapper.toResponse
|
import com.sangdol.roomescape.payment.mapper.toResponse
|
||||||
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||||
@ -33,8 +33,7 @@ class DummyInitializer(
|
|||||||
private val scheduleRepository: ScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val reservationRepository: ReservationRepository,
|
private val reservationRepository: ReservationRepository,
|
||||||
private val paymentRepository: PaymentRepository,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
private val paymentWriter: PaymentWriter
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun createStore(
|
fun createStore(
|
||||||
@ -205,10 +204,12 @@ class DummyInitializer(
|
|||||||
transferDetail = transferDetail
|
transferDetail = transferDetail
|
||||||
)
|
)
|
||||||
|
|
||||||
val paymentEvent = clientConfirmResponse.toEvent(reservationId)
|
val payment = paymentWriter.createPayment(
|
||||||
val payment = paymentRepository.save(paymentEvent.toEntity(IDGenerator.create()))
|
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)
|
return payment.toResponse(detail = detail.toResponse(), cancel = null)
|
||||||
}
|
}
|
||||||
@ -226,14 +227,11 @@ class DummyInitializer(
|
|||||||
cancelReason = cancelReason,
|
cancelReason = cancelReason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return paymentWriter.cancel(
|
||||||
return clientCancelResponse.cancels.toEntity(
|
userId,
|
||||||
id = IDGenerator.create(),
|
payment,
|
||||||
paymentId = payment.id,
|
requestedAt = Instant.now(),
|
||||||
cancelRequestedAt = Instant.now(),
|
clientCancelResponse
|
||||||
canceledBy = userId
|
)
|
||||||
).also {
|
|
||||||
canceledPaymentRepository.save(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
package com.sangdol.roomescape.supports
|
package com.sangdol.roomescape.supports
|
||||||
|
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
|
import com.sangdol.roomescape.payment.business.PaymentWriter
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
@ -44,7 +43,13 @@ abstract class FunSpecSpringbootTest(
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var testAuthUtil: TestAuthUtil
|
private lateinit var userRepository: UserRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var adminRepository: AdminRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var storeRepository: StoreRepository
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
lateinit var dummyInitializer: DummyInitializer
|
lateinit var dummyInitializer: DummyInitializer
|
||||||
@ -52,40 +57,32 @@ abstract class FunSpecSpringbootTest(
|
|||||||
@LocalServerPort
|
@LocalServerPort
|
||||||
var port: Int = 0
|
var port: Int = 0
|
||||||
|
|
||||||
|
lateinit var testAuthUtil: TestAuthUtil
|
||||||
|
|
||||||
override suspend fun beforeSpec(spec: Spec) {
|
override suspend fun beforeSpec(spec: Spec) {
|
||||||
RestAssured.port = port
|
RestAssured.port = port
|
||||||
|
testAuthUtil = TestAuthUtil(userRepository, adminRepository, storeRepository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@TestConfiguration
|
@TestConfiguration
|
||||||
class TestConfig {
|
class TestConfig {
|
||||||
@Bean
|
|
||||||
fun testAuthUtil(
|
|
||||||
userRepository: UserRepository,
|
|
||||||
adminRepository: AdminRepository,
|
|
||||||
storeRepository: StoreRepository
|
|
||||||
): TestAuthUtil {
|
|
||||||
return TestAuthUtil(userRepository, adminRepository, storeRepository)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun dummyInitializer(
|
fun dummyInitializer(
|
||||||
storeRepository: StoreRepository,
|
storeRepository: StoreRepository,
|
||||||
themeRepository: ThemeRepository,
|
themeRepository: ThemeRepository,
|
||||||
scheduleRepository: ScheduleRepository,
|
scheduleRepository: ScheduleRepository,
|
||||||
reservationRepository: ReservationRepository,
|
reservationRepository: ReservationRepository,
|
||||||
paymentRepository: PaymentRepository,
|
paymentWriter: PaymentWriter,
|
||||||
paymentDetailRepository: PaymentDetailRepository,
|
paymentRepository: PaymentRepository
|
||||||
canceledPaymentRepository: CanceledPaymentRepository
|
|
||||||
): DummyInitializer {
|
): DummyInitializer {
|
||||||
return DummyInitializer(
|
return DummyInitializer(
|
||||||
themeRepository = themeRepository,
|
themeRepository = themeRepository,
|
||||||
scheduleRepository = scheduleRepository,
|
scheduleRepository = scheduleRepository,
|
||||||
reservationRepository = reservationRepository,
|
reservationRepository = reservationRepository,
|
||||||
|
paymentWriter = paymentWriter,
|
||||||
paymentRepository = paymentRepository,
|
paymentRepository = paymentRepository,
|
||||||
storeRepository = storeRepository,
|
storeRepository = storeRepository
|
||||||
paymentDetailRepository = paymentDetailRepository,
|
|
||||||
canceledPaymentRepository = canceledPaymentRepository
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,28 +6,21 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
|||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.supports.*
|
import com.sangdol.roomescape.supports.*
|
||||||
import com.sangdol.roomescape.supports.ThemeFixture.createRequest
|
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_DURATION
|
||||||
import com.sangdol.roomescape.theme.business.MIN_PARTICIPANTS
|
import com.sangdol.roomescape.theme.business.MIN_PARTICIPANTS
|
||||||
import com.sangdol.roomescape.theme.business.MIN_PRICE
|
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.exception.ThemeErrorCode
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
|
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
|
||||||
import io.kotest.assertions.assertSoftly
|
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import org.hamcrest.CoreMatchers.equalTo
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.springframework.cache.CacheManager
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
|
|
||||||
class AdminThemeApiTest(
|
class AdminThemeApiTest(
|
||||||
private val themeRepository: ThemeRepository,
|
private val themeRepository: ThemeRepository
|
||||||
private val themeService: ThemeService,
|
|
||||||
private val cacheManager: CacheManager
|
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -489,19 +482,14 @@ class AdminThemeApiTest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("정상 삭제 및 캐시 제거 확인") {
|
test("정상 삭제") {
|
||||||
val token = testAuthUtil.defaultHqAdminLogin().second
|
val token = testAuthUtil.defaultHqAdminLogin().second
|
||||||
val createdTheme = initialize("테스트를 위한 테마 생성") {
|
val createdTheme = initialize("테스트를 위한 테마 생성") {
|
||||||
dummyInitializer.createTheme()
|
dummyInitializer.createTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize("테마 캐시 추가") {
|
|
||||||
themeService.findInfoById(createdTheme.id)
|
|
||||||
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).shouldNotBeNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
runTest(
|
runTest(
|
||||||
token = token,
|
token = testAuthUtil.defaultHqAdminLogin().second,
|
||||||
on = {
|
on = {
|
||||||
delete("/admin/themes/${createdTheme.id}")
|
delete("/admin/themes/${createdTheme.id}")
|
||||||
},
|
},
|
||||||
@ -510,7 +498,6 @@ class AdminThemeApiTest(
|
|||||||
}
|
}
|
||||||
).also {
|
).also {
|
||||||
themeRepository.findByIdOrNull(createdTheme.id) shouldBe null
|
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")
|
val updateRequest = ThemeUpdateRequest(name = "modified")
|
||||||
|
|
||||||
test("정상 수정 및 감사 정보 & 캐시 변경 확인") {
|
test("정상 수정 및 감사 정보 변경 확인") {
|
||||||
val createdThemeId: Long = initialize("테스트를 위한 관리자1의 테마 생성") {
|
val createdThemeId: Long = initialize("테스트를 위한 관리자1의 테마 생성") {
|
||||||
runTest(
|
runTest(
|
||||||
token = testAuthUtil.defaultHqAdminLogin().second,
|
token = testAuthUtil.defaultHqAdminLogin().second,
|
||||||
@ -595,11 +582,6 @@ class AdminThemeApiTest(
|
|||||||
).extract().path("data.id")
|
).extract().path("data.id")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize("테마 캐시 추가") {
|
|
||||||
themeService.findInfoById(createdThemeId)
|
|
||||||
cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java).shouldNotBeNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
val (otherAdmin, otherAdminToken) = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") {
|
val (otherAdmin, otherAdminToken) = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") {
|
||||||
testAuthUtil.adminLogin(
|
testAuthUtil.adminLogin(
|
||||||
AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE)
|
AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE)
|
||||||
@ -622,12 +604,6 @@ class AdminThemeApiTest(
|
|||||||
|
|
||||||
updatedTheme.name shouldBe updateRequest.name
|
updatedTheme.name shouldBe updateRequest.name
|
||||||
updatedTheme.updatedBy shouldBe otherAdmin.id
|
updatedTheme.updatedBy shouldBe otherAdmin.id
|
||||||
|
|
||||||
|
|
||||||
// 캐시 제거 확인
|
|
||||||
assertSoftly(cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java)) {
|
|
||||||
this shouldBe null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.infrastructure.persistence.ThemeRepository
|
||||||
import com.sangdol.roomescape.theme.mapper.toEntity
|
import com.sangdol.roomescape.theme.mapper.toEntity
|
||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
|
||||||
import io.kotest.matchers.collections.shouldContainInOrder
|
import io.kotest.matchers.collections.shouldContainInOrder
|
||||||
import io.kotest.matchers.collections.shouldHaveSize
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
import io.kotest.matchers.comparables.shouldBeLessThan
|
import io.kotest.matchers.comparables.shouldBeLessThan
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import org.hamcrest.CoreMatchers.equalTo
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.springframework.cache.CacheManager
|
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
class ThemeApiTest(
|
class ThemeApiTest(
|
||||||
private val themeRepository: ThemeRepository,
|
private val themeRepository: ThemeRepository
|
||||||
private val cacheManager: CacheManager
|
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
init {
|
init {
|
||||||
context("ID로 테마 정보를 조회한다.") {
|
context("ID로 테마 정보를 조회한다.") {
|
||||||
test("정상 응답 및 캐시 저장 확인") {
|
test("정상 응답") {
|
||||||
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
|
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
|
||||||
dummyInitializer.createTheme()
|
dummyInitializer.createTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).also {
|
|
||||||
it shouldBe null
|
|
||||||
}
|
|
||||||
|
|
||||||
runTest(
|
runTest(
|
||||||
on = {
|
on = {
|
||||||
get("/themes/${createdTheme.id}")
|
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("테마가 없으면 실패한다.") {
|
test("테마가 없으면 실패한다.") {
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -148,6 +148,15 @@ class UserApiTest(
|
|||||||
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("관리자") {
|
||||||
|
runExceptionTest(
|
||||||
|
token = testAuthUtil.defaultStoreAdminLogin().second,
|
||||||
|
method = HttpMethod.GET,
|
||||||
|
endpoint = endpoint,
|
||||||
|
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
|
|||||||
@ -18,9 +18,6 @@ spring:
|
|||||||
init:
|
init:
|
||||||
mode: always
|
mode: always
|
||||||
schema-locations: classpath:schema/schema-mysql.sql
|
schema-locations: classpath:schema/schema-mysql.sql
|
||||||
cache:
|
|
||||||
type: caffeine
|
|
||||||
cache-names: ${CACHE_NAMES:theme-details}
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
import http from 'k6/http';
|
|
||||||
|
|
||||||
export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
|
|
||||||
|
|
||||||
export function generateRandomBase64String(length) {
|
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
||||||
let result = '';
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseIdToString(response) {
|
|
||||||
try {
|
|
||||||
const safeJsonString = response.body.replace(/"(\w*Id|id)"\s*:\s*(\d{16,})/g, '"$1":"$2"');
|
|
||||||
return JSON.parse(safeJsonString);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`JSON parsing failed for VU ${__VU}: ${e}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maxIterations() {
|
|
||||||
const maxIterationsRes = http.get(`http://localhost:8080/tests/max-iterations`)
|
|
||||||
if (maxIterationsRes.status !== 200) {
|
|
||||||
throw new Error('max-iterations 조회 실패')
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxIterationsRes.json('count')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchUsers() {
|
|
||||||
const userCount = Math.round(maxIterations() * 0.5)
|
|
||||||
const userAccountRes = http.get(`http://localhost:8080/tests/users?count=${userCount}`)
|
|
||||||
|
|
||||||
if (userAccountRes.status !== 200) {
|
|
||||||
throw new Error('users 조회 실패')
|
|
||||||
}
|
|
||||||
|
|
||||||
return userAccountRes.json('results')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchStores() {
|
|
||||||
const storeIdListRes = http.get(`http://localhost:8080/tests/stores`)
|
|
||||||
|
|
||||||
if (storeIdListRes.status !== 200) {
|
|
||||||
throw new Error('stores 조회 실패')
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseIdToString(storeIdListRes).results
|
|
||||||
}
|
|
||||||
|
|
||||||
export function login(account, password, principalType) {
|
|
||||||
const loginPayload = JSON.stringify({
|
|
||||||
account: account,
|
|
||||||
password: password,
|
|
||||||
principalType: principalType
|
|
||||||
})
|
|
||||||
const params = { headers: { 'Content-Type': 'application/json' }, tags: { name: '/auth/login' } }
|
|
||||||
|
|
||||||
const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params)
|
|
||||||
|
|
||||||
if (loginRes.status !== 200) {
|
|
||||||
throw new Error(`로그인 실패: ${__VU}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = parseIdToString(loginRes).data
|
|
||||||
if (principalType === 'ADMIN') {
|
|
||||||
return {
|
|
||||||
storeId: body.storeId,
|
|
||||||
accessToken: body.accessToken
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
accessToken: body.accessToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHeaders(token, endpoint) {
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { headers: headers, tags: { name: endpoint } };
|
|
||||||
}
|
|
||||||
@ -1,261 +0,0 @@
|
|||||||
import {
|
|
||||||
BASE_URL,
|
|
||||||
fetchStores,
|
|
||||||
fetchUsers,
|
|
||||||
generateRandomBase64String,
|
|
||||||
getHeaders,
|
|
||||||
login,
|
|
||||||
parseIdToString
|
|
||||||
} from "./common.js";
|
|
||||||
import {check, group, sleep} from 'k6';
|
|
||||||
import {randomIntBetween, randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
|
||||||
import http from 'k6/http';
|
|
||||||
|
|
||||||
export const options = {
|
|
||||||
scenarios: {
|
|
||||||
user_reservation: {
|
|
||||||
executor: 'ramping-vus',
|
|
||||||
startVUs: 0,
|
|
||||||
stages: [
|
|
||||||
{ duration: '3m', target: 500 },
|
|
||||||
{ duration: '2m', target: 1000 },
|
|
||||||
{ duration: '2m', target: 1500 },
|
|
||||||
{ duration: '3m', target: 1500 },
|
|
||||||
{ duration: '3m', target: 0 },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
thresholds: {
|
|
||||||
http_req_duration: ['p(95)<3000'],
|
|
||||||
http_req_failed: ['rate<0.15'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
function randomDayBetween(startFrom, endAt) {
|
|
||||||
const random = randomIntBetween(startFrom, endAt)
|
|
||||||
const targetDate = new Date()
|
|
||||||
targetDate.setDate(targetDate.getDate() + random)
|
|
||||||
|
|
||||||
return targetDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function countForFetchDetails(themeCount) {
|
|
||||||
const random = Math.random()
|
|
||||||
|
|
||||||
if (random < 0.5) {
|
|
||||||
return Math.min(0, themeCount)
|
|
||||||
}
|
|
||||||
if (random < 0.75) {
|
|
||||||
return Math.min(1, themeCount)
|
|
||||||
}
|
|
||||||
if (random < 0.9) {
|
|
||||||
return Math.min(2, themeCount)
|
|
||||||
}
|
|
||||||
return Math.min(randomIntBetween(3, themeCount), themeCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractRandomThemeForFetchDetail(themes) {
|
|
||||||
const count = countForFetchDetails(themes.length)
|
|
||||||
const shuffled = [...themes].sort(() => Math.random() - 0.5)
|
|
||||||
|
|
||||||
return shuffled.slice(0, count).map(t => t.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setup() {
|
|
||||||
const users = fetchUsers()
|
|
||||||
const stores = fetchStores()
|
|
||||||
|
|
||||||
console.log(`회원 수 = ${users.length} 조회 완료`)
|
|
||||||
console.log(`매장 수 = ${stores.length} 조회 완료`)
|
|
||||||
|
|
||||||
return { users, stores }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (data) {
|
|
||||||
const { users, stores } = data;
|
|
||||||
|
|
||||||
const user = randomItem(users)
|
|
||||||
const accessToken = login(user.account, user.password, 'USER').accessToken
|
|
||||||
let storeId = randomItem(stores).storeId
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
console.log(`로그인 실패: token=${accessToken}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let targetDate
|
|
||||||
|
|
||||||
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
|
|
||||||
|
|
||||||
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
|
|
||||||
let searchTrial = 0
|
|
||||||
let schedules
|
|
||||||
|
|
||||||
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 result = check(res, {'일정 조회 성공': (r) => r.status === 200})
|
|
||||||
if (result !== true) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
schedules = parseIdToString(res).data.schedules
|
|
||||||
if (schedules && schedules.length > 0) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
searchTrial++
|
|
||||||
sleep(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schedules.length <= 0) {
|
|
||||||
console.log(`5회 시도에도 일정 조회 실패`)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
group(`일부 테마는 상세 조회`, function () {
|
|
||||||
const themesByStoreAndDate = schedules.map(s => s.theme)
|
|
||||||
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate)
|
|
||||||
|
|
||||||
randomThemesForFetchDetail.forEach(id => {
|
|
||||||
http.get(`${BASE_URL}/themes/${id}`, getHeaders(accessToken, "/themes/${id}"))
|
|
||||||
sleep(10)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableSchedules = schedules.filter((s) => s.schedule.status === 'AVAILABLE')
|
|
||||||
if (availableSchedules.length > 0) {
|
|
||||||
const availableSchedule = randomItem(availableSchedules)
|
|
||||||
availableScheduleId = availableSchedule.schedule.id
|
|
||||||
selectedThemeId = availableSchedule.theme.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!availableScheduleId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isScheduleHeld = false
|
|
||||||
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
|
|
||||||
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken, "/schedules/${id}/hold"))
|
|
||||||
const body = JSON.parse(holdRes.body)
|
|
||||||
|
|
||||||
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
|
|
||||||
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`, { tag: { name: "/themes/${id}"}})
|
|
||||||
selectedThemeInfo = parseIdToString(themeInfoRes).data
|
|
||||||
isScheduleHeld = true
|
|
||||||
} else {
|
|
||||||
const errorCode = body.code
|
|
||||||
const errorMessage = body.message
|
|
||||||
|
|
||||||
console.log(`일정 점유 실패: code=${errorCode}, message=${errorMessage}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isScheduleHeld || !selectedThemeInfo) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let isPendingReservationCreated = false
|
|
||||||
group(`예약 정보 입력 페이지`, function () {
|
|
||||||
let userName, userContact
|
|
||||||
group(`회원 연락처 조회`, function () {
|
|
||||||
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken, "/users/contact"))
|
|
||||||
|
|
||||||
if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) {
|
|
||||||
throw new Error("회원 연락처 조회 과정에서 예외 발생")
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseBody = JSON.parse(userContactRes.body).data
|
|
||||||
userName = responseBody.name
|
|
||||||
userContact = responseBody.phone
|
|
||||||
})
|
|
||||||
|
|
||||||
// 20%의 유저는 예약 정보 입력창에서 나감 => 배치의 자동 활성화 테스트
|
|
||||||
if (Math.random() <= 0.2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep(20)
|
|
||||||
|
|
||||||
group(`예약 정보 입력 및 Pending 예약 생성`, function () {
|
|
||||||
const requirement = `${selectedThemeInfo.name}을 잘부탁드려요!`
|
|
||||||
const participants = randomIntBetween(selectedThemeInfo.minParticipants, selectedThemeInfo.maxParticipants)
|
|
||||||
totalAmount = Math.round(participants * selectedThemeInfo.price)
|
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
scheduleId: availableScheduleId,
|
|
||||||
reserverName: userName,
|
|
||||||
reserverContact: userContact,
|
|
||||||
participantCount: participants,
|
|
||||||
requirement: requirement
|
|
||||||
})
|
|
||||||
|
|
||||||
const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken, "/reservations/pending"))
|
|
||||||
const responseBody = parseIdToString(pendingReservationCreateRes)
|
|
||||||
|
|
||||||
if (pendingReservationCreateRes.status !== 200) {
|
|
||||||
const errorCode = responseBody.code
|
|
||||||
const errorMessage = responseBody.message
|
|
||||||
|
|
||||||
throw new Error(`Pending 예약 중 실패: code=${errorCode}, message=${errorMessage}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
reservationId = responseBody.data.id
|
|
||||||
isPendingReservationCreated = true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isPendingReservationCreated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
group(`결제 및 예약 확정`, function () {
|
|
||||||
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
|
|
||||||
if (Math.random() <= 0.2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentKey = generateRandomBase64String(64)
|
|
||||||
const orderId = generateRandomBase64String(25)
|
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
paymentKey: paymentKey,
|
|
||||||
orderId: orderId,
|
|
||||||
amount: totalAmount,
|
|
||||||
})
|
|
||||||
|
|
||||||
let trial = 0
|
|
||||||
let isConfirmed = false
|
|
||||||
while (trial < 2) {
|
|
||||||
sleep(30)
|
|
||||||
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken, "/orders/${reservationId}/confirm"))
|
|
||||||
|
|
||||||
if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) {
|
|
||||||
isConfirmed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorResponse = JSON.parse(confirmOrderRes.body)
|
|
||||||
console.log(`예약 확정 실패: message=${errorResponse.message}`)
|
|
||||||
trial = errorResponse.trial + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 예약이 확정되었으면 종료, 아니면 임시 확정
|
|
||||||
if (isConfirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep(10)
|
|
||||||
const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`)
|
|
||||||
if (check(temporalConfirmRes, {'임시 예약 확정 성공': (r) => r.status === 200})) {
|
|
||||||
console.log("예약 확정 성공")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('임시 예약 확정 실패')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
import http from 'k6/http';
|
|
||||||
import {check} from 'k6';
|
|
||||||
import exec from 'k6/execution';
|
|
||||||
import {BASE_URL, getHeaders, login, parseIdToString} from "./common.js";
|
|
||||||
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
|
||||||
|
|
||||||
|
|
||||||
const TOTAL_ITERATIONS = 200000;
|
|
||||||
|
|
||||||
export const options = {
|
|
||||||
scenarios: {
|
|
||||||
schedule_creation: {
|
|
||||||
executor: 'shared-iterations',
|
|
||||||
vus: 100,
|
|
||||||
iterations: TOTAL_ITERATIONS,
|
|
||||||
maxDuration: '30m',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
thresholds: {
|
|
||||||
'http_req_duration': ['p(95)<3000'],
|
|
||||||
'http_req_failed': ['rate<0.1'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function setup() {
|
|
||||||
console.log('=== Setup: 테스트 데이터 및 작업 목록 준비 중 ===');
|
|
||||||
|
|
||||||
const themesRes = http.get(`${BASE_URL}/tests/themes`);
|
|
||||||
const themes = parseIdToString(themesRes).results
|
|
||||||
|
|
||||||
const accountsRes = http.get(`${BASE_URL}/tests/admin/accounts`);
|
|
||||||
const accounts = JSON.parse(accountsRes.body).results;
|
|
||||||
|
|
||||||
const dates = generateDates(7);
|
|
||||||
|
|
||||||
console.log(`총 매장 수: ${accounts.length}`);
|
|
||||||
console.log(`총 테마 수: ${themes.length}`);
|
|
||||||
console.log(`생성 기간: ${dates.length}일`);
|
|
||||||
|
|
||||||
const tasks = [];
|
|
||||||
for (const account of accounts) {
|
|
||||||
const loginResult = login(account.account, account.password, 'ADMIN');
|
|
||||||
if (loginResult === null) {
|
|
||||||
console.error(`[Setup] 로그인 실패: ${account.account}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const {storeId, accessToken} = loginResult;
|
|
||||||
|
|
||||||
// 5 ~ ${themes.size} 인 random 숫자 생성
|
|
||||||
const selectedThemes = selectRandomThemes(themes, randomIntBetween(5, themes.length));
|
|
||||||
|
|
||||||
for (const theme of selectedThemes) {
|
|
||||||
for (const date of dates) {
|
|
||||||
for (const time of theme.times) {
|
|
||||||
tasks.push({
|
|
||||||
storeId,
|
|
||||||
accessToken,
|
|
||||||
date,
|
|
||||||
time: time.startFrom,
|
|
||||||
themeId: theme.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`총 생성할 스케줄 수(iterations): ${tasks.length}`);
|
|
||||||
|
|
||||||
return {tasks};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (data) {
|
|
||||||
// 👈 3. 현재 반복 횟수가 준비된 작업 수를 초과하는지 확인
|
|
||||||
const taskIndex = exec.scenario.iterationInTest;
|
|
||||||
|
|
||||||
if (taskIndex >= data.tasks.length) {
|
|
||||||
// 첫 번째로 이 조건에 도달한 VU가 테스트를 종료
|
|
||||||
if (taskIndex === data.tasks.length) {
|
|
||||||
console.log('모든 스케쥴 생성 완료. 테스트 종료');
|
|
||||||
exec.test.abort();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const task = data.tasks[taskIndex];
|
|
||||||
|
|
||||||
if (!task) {
|
|
||||||
console.log(`[VU ${__VU}] 알 수 없는 오류: task가 없습니다. (index: ${taskIndex})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createSchedule(task.storeId, task.accessToken, task);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSchedule(storeId, accessToken, schedule) {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
date: schedule.date,
|
|
||||||
time: schedule.time,
|
|
||||||
themeId: schedule.themeId,
|
|
||||||
});
|
|
||||||
const params = getHeaders(accessToken, "/admin/stores/${id}/schedules")
|
|
||||||
const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params);
|
|
||||||
|
|
||||||
const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201});
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateDates(days) {
|
|
||||||
const dates = [];
|
|
||||||
const today = new Date();
|
|
||||||
for (let i = 1; i <= days; i++) {
|
|
||||||
const date = new Date(today);
|
|
||||||
date.setDate(today.getDate() + i);
|
|
||||||
dates.push(date.toISOString().split('T')[0]);
|
|
||||||
}
|
|
||||||
return dates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRandomThemes(themes, count) {
|
|
||||||
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
|
||||||
return shuffled.slice(0, Math.min(count, themes.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function teardown(data) {
|
|
||||||
if (data.tasks) {
|
|
||||||
console.log(`\n=== 테스트 완료: 총 ${data.tasks.length}개의 스케줄 생성을 시도했습니다. ===`);
|
|
||||||
} else {
|
|
||||||
console.log('\n=== 테스트 완료: setup 단계에서 오류가 발생하여 작업을 실행하지 못했습니다. ===');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import {fetchUsers, login} from "./common.js";
|
|
||||||
import {randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
|
||||||
|
|
||||||
export const options = {
|
|
||||||
scenarios: {
|
|
||||||
login: {
|
|
||||||
executor: 'ramping-vus',
|
|
||||||
startVUs: 0,
|
|
||||||
stages: [
|
|
||||||
{ duration: '1m', target: 100 },
|
|
||||||
{ duration: '1m', target: 200 },
|
|
||||||
{ duration: '1m', target: 300 },
|
|
||||||
{ duration: '1m', target: 300 },
|
|
||||||
{ duration: '1m', target: 400 },
|
|
||||||
{ duration: '1m', target: 500 },
|
|
||||||
{ duration: '2m', target: 0 },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setup() {
|
|
||||||
const users = fetchUsers()
|
|
||||||
|
|
||||||
console.log(`${users.length}명의 회원 준비`)
|
|
||||||
|
|
||||||
return { users }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (data) {
|
|
||||||
const user = randomItem(data.users)
|
|
||||||
const token = login(user.account, user.password, 'USER').accessToken
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user