generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #56 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> <img width="1163" alt="스크린샷 2025-10-09 18.26.43.png" src="attachments/b1651431-c1c4-4198-84c8-2019bde70dd6"> - '결제 요청 API가 호출된 이상 사용자는 결제를 시도한 것으로 간주한다' + 'PG사 정상 응답만 오면 사용자는 결제를 성공한 것이다' 의 관점으로 구현 - 결제 요청 API가 호출되면, 이미 예약이 완료, 취소, 만료된 것이 아니라면 검증 후 해당 예약을 배치가 처리하지 못하게 PAYMENT_IN_PROGRESS로 변경 - PG사 결제가 성공하면, 이후의 결제 & 예약 정보 저장의 성공 여부와 무관하게 일단 API는 성공 응답 전송 - 매 결제 시도는 성공 / 실패 여부와 무관하게 이력 저장 => 결제 시도 횟수가 N번 이상이면 프론트엔드에서 특정 처리(=> 현장결제 페이지 안내 예정. 현재 바로는 구현 계획 X) - 기존 배치와의 경합 + 데드락 방지를 위해 배치 작업을 조회 -> 수정 두 단계로 변경했고, 조회 단계에서는 SKIP LOCKED 사용. ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> - 새로 통합한 Order 관련 API 테스트 및 기존 배치와의 경합 상황 테스트 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> Reviewed-on: #57 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
parent
8215492eea
commit
047e4a395b
@ -1,6 +1,5 @@
|
|||||||
package com.sangdol.common.web.exception
|
package com.sangdol.common.web.exception
|
||||||
|
|
||||||
import com.sangdol.common.log.constant.LogType
|
|
||||||
import com.sangdol.common.types.exception.CommonErrorCode
|
import com.sangdol.common.types.exception.CommonErrorCode
|
||||||
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
|
||||||
@ -31,7 +30,7 @@ class GlobalExceptionHandler(
|
|||||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
val errorResponse = CommonErrorResponse(errorCode)
|
val errorResponse = CommonErrorResponse(errorCode)
|
||||||
|
|
||||||
logException(servletRequest, httpStatus, errorResponse, e)
|
log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(httpStatus.value())
|
.status(httpStatus.value())
|
||||||
@ -57,7 +56,7 @@ class GlobalExceptionHandler(
|
|||||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
val errorResponse = CommonErrorResponse(errorCode)
|
val errorResponse = CommonErrorResponse(errorCode)
|
||||||
|
|
||||||
logException(servletRequest, httpStatus, errorResponse, e)
|
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(httpStatus.value())
|
.status(httpStatus.value())
|
||||||
@ -75,30 +74,26 @@ class GlobalExceptionHandler(
|
|||||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
val errorResponse = CommonErrorResponse(errorCode)
|
val errorResponse = CommonErrorResponse(errorCode)
|
||||||
|
|
||||||
logException(servletRequest, httpStatus, errorResponse, e)
|
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(httpStatus.value())
|
.status(httpStatus.value())
|
||||||
.body(errorResponse)
|
.body(errorResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logException(
|
private fun convertExceptionLogMessage(
|
||||||
servletRequest: HttpServletRequest,
|
servletRequest: HttpServletRequest,
|
||||||
httpStatus: HttpStatus,
|
httpStatus: HttpStatus,
|
||||||
errorResponse: CommonErrorResponse,
|
errorResponse: CommonErrorResponse,
|
||||||
exception: Exception
|
exception: Exception
|
||||||
) {
|
): String {
|
||||||
val type = if (httpStatus.isClientError()) LogType.APPLICATION_FAILURE else LogType.UNHANDLED_EXCEPTION
|
|
||||||
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
|
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
|
||||||
|
|
||||||
val logMessage = messageConverter.convertToResponseMessage(
|
return messageConverter.convertToErrorResponseMessage(
|
||||||
type = type,
|
|
||||||
servletRequest = servletRequest,
|
servletRequest = servletRequest,
|
||||||
httpStatusCode = httpStatus.value(),
|
httpStatus = httpStatus,
|
||||||
responseBody = errorResponse,
|
responseBody = errorResponse,
|
||||||
exception = actualException
|
exception = actualException
|
||||||
)
|
)
|
||||||
|
|
||||||
log.warn { logMessage }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.sangdol.common.web.support.log
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.sangdol.common.log.constant.LogType
|
import com.sangdol.common.log.constant.LogType
|
||||||
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
class WebLogMessageConverter(
|
class WebLogMessageConverter(
|
||||||
@ -49,4 +50,19 @@ class WebLogMessageConverter(
|
|||||||
|
|
||||||
return objectMapper.writeValueAsString(payload)
|
return objectMapper.writeValueAsString(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun convertToErrorResponseMessage(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
httpStatus: HttpStatus,
|
||||||
|
responseBody: Any? = null,
|
||||||
|
exception: Exception? = null,
|
||||||
|
): String {
|
||||||
|
val type = if (httpStatus.isClientError()) {
|
||||||
|
LogType.APPLICATION_FAILURE
|
||||||
|
} else {
|
||||||
|
LogType.UNHANDLED_EXCEPTION
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -168,5 +168,27 @@ class WebLogMessageConverterTest : FunSpec({
|
|||||||
this["exception"] shouldBe null
|
this["exception"] shouldBe null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") {
|
||||||
|
val result = converter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
|
httpStatus = HttpStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
|
||||||
|
this["type"] shouldBe LogType.APPLICATION_FAILURE.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") {
|
||||||
|
val result = converter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
|
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
|
||||||
|
this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
|
|||||||
paymentKey: string;
|
paymentKey: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
paymentType: PaymentType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentCancelRequest {
|
export interface PaymentCancelRequest {
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import type { Difficulty } from '@_api/theme/themeTypes';
|
|
||||||
|
|
||||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||||
|
|
||||||
export const ScheduleStatus = {
|
export const ScheduleStatus = {
|
||||||
@ -40,16 +38,35 @@ export interface AdminScheduleSummaryListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
|
export interface ScheduleResponse {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startFrom: string;
|
||||||
|
endAt: string;
|
||||||
|
status: ScheduleStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleThemeInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleStoreInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleWithStoreAndThemeResponse {
|
||||||
|
schedule: ScheduleResponse,
|
||||||
|
theme: ScheduleThemeInfo,
|
||||||
|
store: ScheduleStoreInfo,
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScheduleWithThemeResponse {
|
export interface ScheduleWithThemeResponse {
|
||||||
id: string,
|
schedule: ScheduleResponse,
|
||||||
startFrom: string,
|
theme: ScheduleThemeInfo
|
||||||
endAt: string,
|
|
||||||
themeId: string,
|
|
||||||
themeName: string,
|
|
||||||
themeDifficulty: Difficulty,
|
|
||||||
status: ScheduleStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleWithThemeListResponse {
|
export interface ScheduleWithThemeListResponse {
|
||||||
schedules: ScheduleWithThemeResponse[];
|
schedules: ScheduleWithThemeResponse[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import {isLoginRequiredError} from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
||||||
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
|
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
|
||||||
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
import { type ReservationData } from '@_api/reservation/reservationTypes';
|
||||||
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
||||||
import {getStores} from '@_api/store/storeAPI';
|
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
||||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
import { getStores } from '@_api/store/storeAPI';
|
||||||
import {fetchThemeById} from '@_api/theme/themeAPI';
|
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
||||||
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
import { fetchThemeById } from '@_api/theme/themeAPI';
|
||||||
|
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {type ReservationData} from '@_api/reservation/reservationTypes';
|
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||||
import {formatDate} from 'src/util/DateTimeFormatter';
|
|
||||||
|
|
||||||
const ReservationStep1Page: React.FC = () => {
|
const ReservationStep1Page: React.FC = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
fetchSchedules(selectedStore.id, dateStr)
|
fetchSchedules(selectedStore.id, dateStr)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||||
const key = schedule.themeName;
|
const key = schedule.theme.name;
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
acc[key].push(schedule);
|
acc[key].push(schedule);
|
||||||
return acc;
|
return acc;
|
||||||
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
const handleConfirmReservation = () => {
|
const handleConfirmReservation = () => {
|
||||||
if (!selectedSchedule) return;
|
if (!selectedSchedule) return;
|
||||||
|
|
||||||
holdSchedule(selectedSchedule.id)
|
holdSchedule(selectedSchedule.schedule.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
fetchThemeById(selectedSchedule.themeId).then(res => {
|
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
||||||
const reservationData: ReservationData = {
|
const reservationData: ReservationData = {
|
||||||
scheduleId: selectedSchedule.id,
|
scheduleId: selectedSchedule.schedule.id,
|
||||||
store: {
|
store: {
|
||||||
id: selectedStore!.id,
|
id: selectedStore!.id,
|
||||||
name: selectedStore!.name,
|
name: selectedStore!.name,
|
||||||
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
maxParticipants: res.maxParticipants,
|
maxParticipants: res.maxParticipants,
|
||||||
},
|
},
|
||||||
date: selectedDate.toLocaleDateString('en-CA'),
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
startFrom: selectedSchedule.startFrom,
|
startFrom: selectedSchedule.schedule.startFrom,
|
||||||
endAt: selectedSchedule.endAt,
|
endAt: selectedSchedule.schedule.endAt,
|
||||||
};
|
};
|
||||||
navigate('/reservation/form', {state: reservationData});
|
navigate('/reservation/form', {state: reservationData});
|
||||||
}).catch(handleError);
|
}).catch(handleError);
|
||||||
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<h3>3. 시간 선택</h3>
|
<h3>3. 시간 선택</h3>
|
||||||
<div className="schedule-list">
|
<div className="schedule-list">
|
||||||
{Object.keys(schedulesByTheme).length > 0 ? (
|
{Object.keys(schedulesByTheme).length > 0 ? (
|
||||||
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
|
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
|
||||||
<div key={themeName} className="theme-schedule-group">
|
<div key={themeName} className="theme-schedule-group">
|
||||||
<div className="theme-header">
|
<div className="theme-header">
|
||||||
<h4>{themeName}</h4>
|
<h4>{themeName}</h4>
|
||||||
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
||||||
className="theme-detail-button">상세보기
|
className="theme-detail-button">상세보기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="time-slots">
|
<div className="time-slots">
|
||||||
{schedules.map(schedule => (
|
{scheduleAndTheme.map(schedule => (
|
||||||
<div
|
<div
|
||||||
key={schedule.id}
|
key={schedule.schedule.id}
|
||||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||||
>
|
>
|
||||||
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
||||||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<div className="modal-section modal-info-grid">
|
<div className="modal-section modal-info-grid">
|
||||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||||
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
||||||
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { confirm } from '@_api/order/orderAPI';
|
||||||
import { confirmPayment } from '@_api/payment/paymentAPI';
|
import type { BookingErrorResponse } from '@_api/order/orderTypes';
|
||||||
import { type PaymentConfirmRequest, PaymentType } 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';
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||||
@ -21,17 +22,6 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
|
||||||
|
|
||||||
const handleError = (err: any) => {
|
|
||||||
if (isLoginRequiredError(err)) {
|
|
||||||
alert('로그인이 필요해요.');
|
|
||||||
navigate('/login', { state: { from: location } });
|
|
||||||
} else {
|
|
||||||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
|
||||||
alert(message);
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reservationId) {
|
if (!reservationId) {
|
||||||
alert('잘못된 접근입니다.');
|
alert('잘못된 접근입니다.');
|
||||||
@ -66,7 +56,7 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const generateRandomString = () =>
|
const generateRandomString = () =>
|
||||||
crypto.randomUUID().replace(/-/g, '');
|
crypto.randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
|
|
||||||
paymentWidgetRef.current.requestPayment({
|
paymentWidgetRef.current.requestPayment({
|
||||||
orderId: generateRandomString(),
|
orderId: generateRandomString(),
|
||||||
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
paymentKey: data.paymentKey,
|
paymentKey: data.paymentKey,
|
||||||
orderId: data.orderId,
|
orderId: data.orderId,
|
||||||
amount: totalPrice,
|
amount: totalPrice,
|
||||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
|
||||||
};
|
};
|
||||||
|
confirm(reservationId, paymentData)
|
||||||
confirmPayment(reservationId, paymentData)
|
|
||||||
.then(() => {
|
|
||||||
return confirmReservation(reservationId);
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
alert('결제가 완료되었어요!');
|
alert('결제가 완료되었어요!');
|
||||||
navigate('/reservation/success', {
|
navigate('/reservation/success', {
|
||||||
@ -97,10 +82,50 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(handleError);
|
.catch(err => {
|
||||||
|
const error = err as AxiosError<BookingErrorResponse>;
|
||||||
|
const errorCode = error.response?.data?.code;
|
||||||
|
const errorMessage = error.response?.data?.message;
|
||||||
|
|
||||||
|
if (errorCode === 'B000') {
|
||||||
|
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
|
||||||
|
navigate('/reservation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trial = error.response?.data?.trial || 0;
|
||||||
|
if (trial < 2) {
|
||||||
|
alert(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(errorMessage);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
|
||||||
|
|
||||||
|
if (agreeToOnsitePayment) {
|
||||||
|
confirmReservation(reservationId)
|
||||||
|
.then(() => {
|
||||||
|
navigate('/reservation/success', {
|
||||||
|
state: {
|
||||||
|
storeName,
|
||||||
|
themeName,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
participantCount,
|
||||||
|
totalPrice,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
console.error("Payment request error:", error);
|
console.error("Payment request error:", error);
|
||||||
alert("결제 요청 중 오류가 발생했습니다.");
|
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package com.sangdol.roomescape.admin.business
|
package com.sangdol.roomescape.admin.business
|
||||||
|
|
||||||
import com.sangdol.roomescape.admin.business.dto.AdminLoginCredentials
|
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
|
||||||
import com.sangdol.roomescape.admin.business.dto.toCredentials
|
import com.sangdol.roomescape.admin.mapper.toCredentials
|
||||||
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
||||||
import com.sangdol.roomescape.admin.exception.AdminException
|
import com.sangdol.roomescape.admin.exception.AdminException
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
package com.sangdol.roomescape.admin.business.dto
|
package com.sangdol.roomescape.admin.dto
|
||||||
|
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
|
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
||||||
import com.sangdol.roomescape.auth.web.LoginCredentials
|
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||||
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
|
|
||||||
data class AdminLoginCredentials(
|
data class AdminLoginCredentials(
|
||||||
override val id: Long,
|
override val id: Long,
|
||||||
@ -22,18 +21,9 @@ data class AdminLoginCredentials(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun AdminEntity.toCredentials() = AdminLoginCredentials(
|
|
||||||
id = this.id,
|
|
||||||
password = this.password,
|
|
||||||
name = this.name,
|
|
||||||
type = this.type,
|
|
||||||
storeId = this.storeId,
|
|
||||||
permissionLevel = this.permissionLevel
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminLoginSuccessResponse(
|
data class AdminLoginSuccessResponse(
|
||||||
override val accessToken: String,
|
override val accessToken: String,
|
||||||
override val name: String,
|
override val name: String,
|
||||||
val type: AdminType,
|
val type: AdminType,
|
||||||
val storeId: Long?,
|
val storeId: Long?,
|
||||||
) : LoginSuccessResponse()
|
) : LoginSuccessResponse()
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.sangdol.roomescape.admin.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
|
||||||
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
|
||||||
|
|
||||||
|
fun AdminEntity.toCredentials() = AdminLoginCredentials(
|
||||||
|
id = this.id,
|
||||||
|
password = this.password,
|
||||||
|
name = this.name,
|
||||||
|
type = this.type,
|
||||||
|
storeId = this.storeId,
|
||||||
|
permissionLevel = this.permissionLevel
|
||||||
|
)
|
||||||
@ -1,10 +1,14 @@
|
|||||||
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.PrincipalType
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
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.*
|
|
||||||
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
|
||||||
|
|||||||
@ -3,8 +3,8 @@ package com.sangdol.roomescape.auth.business
|
|||||||
import com.sangdol.common.persistence.IDGenerator
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||||
import com.sangdol.roomescape.auth.web.LoginContext
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
import com.sangdol.roomescape.auth.web.PrincipalType
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
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.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business.domain
|
||||||
|
|
||||||
|
enum class PrincipalType {
|
||||||
|
USER, ADMIN
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.sangdol.roomescape.auth.docs
|
package com.sangdol.roomescape.auth.docs
|
||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.web.LoginRequest
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
|
|||||||
@ -1,21 +1,12 @@
|
|||||||
package com.sangdol.roomescape.auth.web
|
package com.sangdol.roomescape.auth.dto
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
|
||||||
enum class PrincipalType {
|
|
||||||
USER, ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LoginContext(
|
data class LoginContext(
|
||||||
val ipAddress: String,
|
val ipAddress: String,
|
||||||
val userAgent: String,
|
val userAgent: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun HttpServletRequest.toLoginContext() = LoginContext(
|
|
||||||
ipAddress = this.remoteAddr,
|
|
||||||
userAgent = this.getHeader("User-Agent")
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
val account: String,
|
val account: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
@ -1,7 +1,7 @@
|
|||||||
package com.sangdol.roomescape.auth.infrastructure.persistence
|
package com.sangdol.roomescape.auth.infrastructure.persistence
|
||||||
|
|
||||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
import com.sangdol.roomescape.auth.web.PrincipalType
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
|
|||||||
@ -3,6 +3,9 @@ package com.sangdol.roomescape.auth.web
|
|||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.business.AuthService
|
import com.sangdol.roomescape.auth.business.AuthService
|
||||||
import com.sangdol.roomescape.auth.docs.AuthAPI
|
import com.sangdol.roomescape.auth.docs.AuthAPI
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@ -36,3 +39,8 @@ class AuthController(
|
|||||||
return ResponseEntity.ok().build()
|
return ResponseEntity.ok().build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun HttpServletRequest.toLoginContext() = LoginContext(
|
||||||
|
ipAddress = this.remoteAddr,
|
||||||
|
userAgent = this.getHeader("User-Agent")
|
||||||
|
)
|
||||||
|
|||||||
@ -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] 작업 저장 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
package com.sangdol.roomescape.order.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
|
import com.sangdol.roomescape.order.exception.OrderException
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
|
||||||
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class OrderService(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val reservationService: ReservationService,
|
||||||
|
private val scheduleService: ScheduleService,
|
||||||
|
private val paymentService: PaymentService,
|
||||||
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
|
private val orderValidator: OrderValidator,
|
||||||
|
private val paymentAttemptRepository: PaymentAttemptRepository,
|
||||||
|
private val orderPostProcessorService: OrderPostProcessorService
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
||||||
|
val trial = paymentAttemptRepository.countByReservationId(reservationId)
|
||||||
|
val paymentKey = paymentConfirmRequest.paymentKey
|
||||||
|
|
||||||
|
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
|
validateAndMarkInProgress(reservationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val paymentClientResponse: PaymentGatewayResponse =
|
||||||
|
requestConfirmPayment(reservationId, paymentConfirmRequest)
|
||||||
|
|
||||||
|
orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
||||||
|
e.errorCode
|
||||||
|
} else {
|
||||||
|
OrderErrorCode.BOOKING_UNEXPECTED_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
throw OrderException(errorCode, e.message ?: errorCode.message, trial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateAndMarkInProgress(reservationId: Long) {
|
||||||
|
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
||||||
|
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
||||||
|
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
orderValidator.validateCanConfirm(reservation, schedule)
|
||||||
|
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" }
|
||||||
|
} catch (e: OrderException) {
|
||||||
|
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package com.sangdol.roomescape.order.business
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.KoreaDateTime
|
||||||
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
|
import com.sangdol.roomescape.order.exception.OrderException
|
||||||
|
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class OrderValidator(
|
||||||
|
private val paymentAttemptRepository: PaymentAttemptRepository
|
||||||
|
) {
|
||||||
|
fun validateCanConfirm(
|
||||||
|
reservation: ReservationStateResponse,
|
||||||
|
schedule: ScheduleStateResponse
|
||||||
|
) {
|
||||||
|
if (paymentAttemptRepository.isSuccessAttemptExists(reservation.id)) {
|
||||||
|
log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservation.id}" }
|
||||||
|
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateReservationStatus(reservation)
|
||||||
|
validateScheduleStatus(schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateReservationStatus(reservation: ReservationStateResponse) {
|
||||||
|
when (reservation.status) {
|
||||||
|
ReservationStatus.CONFIRMED -> {
|
||||||
|
log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" }
|
||||||
|
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
|
||||||
|
}
|
||||||
|
ReservationStatus.EXPIRED -> {
|
||||||
|
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
|
||||||
|
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||||
|
}
|
||||||
|
ReservationStatus.CANCELED -> {
|
||||||
|
log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" }
|
||||||
|
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
|
||||||
|
if (schedule.status != ScheduleStatus.HOLD) {
|
||||||
|
log.info { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
|
||||||
|
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
||||||
|
val nowDateTime = KoreaDateTime.now()
|
||||||
|
if (scheduleDateTime.isBefore(nowDateTime)) {
|
||||||
|
log.info { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" }
|
||||||
|
throw OrderException(OrderErrorCode.PAST_SCHEDULE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.sangdol.roomescape.order.docs
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
|
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
|
||||||
|
interface OrderAPI {
|
||||||
|
|
||||||
|
@UserOnly
|
||||||
|
@Operation(summary = "결제 및 예약 완료 처리")
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200"))
|
||||||
|
fun confirm(
|
||||||
|
@PathVariable("reservationId") reservationId: Long,
|
||||||
|
@RequestBody request: PaymentConfirmRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>>
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.sangdol.roomescape.order.exception
|
||||||
|
|
||||||
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
|
||||||
|
enum class OrderErrorCode(
|
||||||
|
override val httpStatus: HttpStatus,
|
||||||
|
override val errorCode: String,
|
||||||
|
override val message: String
|
||||||
|
) : ErrorCode {
|
||||||
|
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
||||||
|
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
||||||
|
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||||
|
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||||
|
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
||||||
|
|
||||||
|
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||||
|
;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.sangdol.roomescape.order.exception
|
||||||
|
|
||||||
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
|
|
||||||
|
class OrderException(
|
||||||
|
override val errorCode: ErrorCode,
|
||||||
|
override val message: String = errorCode.message,
|
||||||
|
var trial: Long = 0
|
||||||
|
) : RoomescapeException(errorCode, message)
|
||||||
|
|
||||||
|
class OrderErrorResponse(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val trial: Long
|
||||||
|
)
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.sangdol.roomescape.order.web
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
|
import com.sangdol.roomescape.order.business.OrderService
|
||||||
|
import com.sangdol.roomescape.order.docs.OrderAPI
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/orders")
|
||||||
|
class OrderController(
|
||||||
|
private val orderService: OrderService
|
||||||
|
) : OrderAPI {
|
||||||
|
|
||||||
|
@PostMapping("/{reservationId}/confirm")
|
||||||
|
override fun confirm(
|
||||||
|
@PathVariable("reservationId") reservationId: Long,
|
||||||
|
@RequestBody request: PaymentConfirmRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
|
orderService.confirm(reservationId, request)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,14 @@
|
|||||||
package com.sangdol.roomescape.payment.business
|
package com.sangdol.roomescape.payment.business
|
||||||
|
|
||||||
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.dto.*
|
||||||
|
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.exception.PaymentException
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
|
|
||||||
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.web.*
|
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.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@ -24,33 +25,54 @@ class PaymentService(
|
|||||||
private val paymentWriter: PaymentWriter,
|
private val paymentWriter: PaymentWriter,
|
||||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
) {
|
) {
|
||||||
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse {
|
fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse {
|
||||||
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm(
|
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
||||||
paymentKey = request.paymentKey,
|
try {
|
||||||
orderId = request.orderId,
|
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
|
||||||
amount = request.amount,
|
log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" }
|
||||||
)
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
when(e) {
|
||||||
|
is ExternalPaymentException -> {
|
||||||
|
val errorCode = if (e.httpStatusCode in 400..<500) {
|
||||||
|
PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
||||||
|
} else {
|
||||||
|
PaymentErrorCode.PAYMENT_PROVIDER_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) {
|
||||||
val payment: PaymentEntity = paymentWriter.createPayment(
|
"${errorCode.message}(${e.message})"
|
||||||
reservationId = reservationId,
|
} else {
|
||||||
orderId = request.orderId,
|
errorCode.message
|
||||||
paymentType = request.paymentType,
|
}
|
||||||
paymentClientConfirmResponse = clientConfirmResponse
|
|
||||||
)
|
|
||||||
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
|
|
||||||
|
|
||||||
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
|
throw PaymentException(errorCode, message)
|
||||||
} ?: run {
|
}
|
||||||
log.warn { "[confirm] 결제 확정 중 예상치 못한 null 반환" }
|
else -> {
|
||||||
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
log.warn(e) { "[requestConfirm] 예상치 못한 결제 실패: paymentKey=${request.paymentKey}" }
|
||||||
|
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel(
|
val clientCancelResponse: PaymentGatewayCancelResponse = paymentClient.cancel(
|
||||||
paymentKey = payment.paymentKey,
|
paymentKey = payment.paymentKey,
|
||||||
amount = payment.totalAmount,
|
amount = payment.totalAmount,
|
||||||
cancelReason = request.cancelReason
|
cancelReason = request.cancelReason
|
||||||
@ -69,16 +91,16 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? {
|
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
|
||||||
log.info { "[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) }
|
||||||
val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) }
|
val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) }
|
||||||
|
|
||||||
return payment?.toDetailResponse(
|
return payment?.toResponse(
|
||||||
detail = paymentDetail?.toPaymentDetailResponse(),
|
detail = paymentDetail?.toResponse(),
|
||||||
cancel = cancelDetail?.toCancelDetailResponse()
|
cancel = cancelDetail?.toResponse()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,13 @@ package com.sangdol.roomescape.payment.business
|
|||||||
import com.sangdol.common.persistence.IDGenerator
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
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.client.*
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
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 com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
@ -24,35 +28,31 @@ class PaymentWriter(
|
|||||||
|
|
||||||
fun createPayment(
|
fun createPayment(
|
||||||
reservationId: Long,
|
reservationId: Long,
|
||||||
orderId: String,
|
paymentGatewayResponse: PaymentGatewayResponse
|
||||||
paymentType: PaymentType,
|
|
||||||
paymentClientConfirmResponse: PaymentClientConfirmResponse
|
|
||||||
): PaymentEntity {
|
): PaymentEntity {
|
||||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
|
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentGatewayResponse.paymentKey}" }
|
||||||
|
|
||||||
return paymentClientConfirmResponse.toEntity(
|
return paymentGatewayResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also {
|
||||||
id = idGenerator.create(), reservationId, orderId, paymentType
|
|
||||||
).also {
|
|
||||||
paymentRepository.save(it)
|
paymentRepository.save(it)
|
||||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
|
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createDetail(
|
fun createDetail(
|
||||||
paymentResponse: PaymentClientConfirmResponse,
|
paymentGatewayResponse: PaymentGatewayResponse,
|
||||||
paymentId: Long,
|
paymentId: Long,
|
||||||
): PaymentDetailEntity {
|
): PaymentDetailEntity {
|
||||||
val method: PaymentMethod = paymentResponse.method
|
val method: PaymentMethod = paymentGatewayResponse.method
|
||||||
val id = idGenerator.create()
|
val id = idGenerator.create()
|
||||||
|
|
||||||
if (method == PaymentMethod.TRANSFER) {
|
if (method == PaymentMethod.TRANSFER) {
|
||||||
return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId))
|
return paymentDetailRepository.save(paymentGatewayResponse.toTransferDetailEntity(id, paymentId))
|
||||||
}
|
}
|
||||||
if (method == PaymentMethod.EASY_PAY && paymentResponse.card == null) {
|
if (method == PaymentMethod.EASY_PAY && paymentGatewayResponse.card == null) {
|
||||||
return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
return paymentDetailRepository.save(paymentGatewayResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
||||||
}
|
}
|
||||||
if (paymentResponse.card != null) {
|
if (paymentGatewayResponse.card != null) {
|
||||||
return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId))
|
return paymentDetailRepository.save(paymentGatewayResponse.toCardDetailEntity(id, paymentId))
|
||||||
}
|
}
|
||||||
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
}
|
}
|
||||||
@ -61,7 +61,7 @@ class PaymentWriter(
|
|||||||
userId: Long,
|
userId: Long,
|
||||||
payment: PaymentEntity,
|
payment: PaymentEntity,
|
||||||
requestedAt: Instant,
|
requestedAt: Instant,
|
||||||
cancelResponse: PaymentClientCancelResponse
|
cancelResponse: PaymentGatewayCancelResponse
|
||||||
): CanceledPaymentEntity {
|
): CanceledPaymentEntity {
|
||||||
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
|
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.common
|
package com.sangdol.roomescape.payment.business.domain
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator
|
import com.fasterxml.jackson.annotation.JsonCreator
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.sangdol.roomescape.payment.business.domain
|
||||||
|
|
||||||
|
enum class UserFacingPaymentErrorCode {
|
||||||
|
ALREADY_PROCESSED_PAYMENT,
|
||||||
|
EXCEED_MAX_CARD_INSTALLMENT_PLAN,
|
||||||
|
NOT_ALLOWED_POINT_USE,
|
||||||
|
INVALID_REJECT_CARD,
|
||||||
|
BELOW_MINIMUM_AMOUNT,
|
||||||
|
INVALID_CARD_EXPIRATION,
|
||||||
|
INVALID_STOPPED_CARD,
|
||||||
|
EXCEED_MAX_DAILY_PAYMENT_COUNT,
|
||||||
|
NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT,
|
||||||
|
INVALID_CARD_INSTALLMENT_PLAN,
|
||||||
|
NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN,
|
||||||
|
EXCEED_MAX_PAYMENT_AMOUNT,
|
||||||
|
INVALID_CARD_LOST_OR_STOLEN,
|
||||||
|
RESTRICTED_TRANSFER_ACCOUNT,
|
||||||
|
INVALID_CARD_NUMBER,
|
||||||
|
EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT,
|
||||||
|
EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT,
|
||||||
|
CARD_PROCESSING_ERROR,
|
||||||
|
EXCEED_MAX_AMOUNT,
|
||||||
|
INVALID_ACCOUNT_INFO_RE_REGISTER,
|
||||||
|
NOT_AVAILABLE_PAYMENT,
|
||||||
|
EXCEED_MAX_MONTHLY_PAYMENT_AMOUNT,
|
||||||
|
REJECT_ACCOUNT_PAYMENT,
|
||||||
|
REJECT_CARD_PAYMENT,
|
||||||
|
REJECT_CARD_COMPANY,
|
||||||
|
FORBIDDEN_REQUEST,
|
||||||
|
EXCEED_MAX_AUTH_COUNT,
|
||||||
|
EXCEED_MAX_ONE_DAY_AMOUNT,
|
||||||
|
NOT_AVAILABLE_BANK,
|
||||||
|
INVALID_PASSWORD,
|
||||||
|
FDS_ERROR,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun contains(code: String): Boolean {
|
||||||
|
return entries.any { it.name == code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,16 +4,15 @@ 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.auth.web.support.UserOnly
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.payment.web.PaymentCancelRequest
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest
|
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||||
import com.sangdol.roomescape.payment.web.PaymentCreateResponse
|
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
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
|
|
||||||
interface PaymentAPI {
|
interface PaymentAPI {
|
||||||
|
|
||||||
@ -21,9 +20,8 @@ interface PaymentAPI {
|
|||||||
@Operation(summary = "결제 승인")
|
@Operation(summary = "결제 승인")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun confirmPayment(
|
fun confirmPayment(
|
||||||
@RequestParam(required = true) reservationId: Long,
|
|
||||||
@Valid @RequestBody request: PaymentConfirmRequest
|
@Valid @RequestBody request: PaymentConfirmRequest
|
||||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>>
|
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>>
|
||||||
|
|
||||||
@Operation(summary = "결제 취소")
|
@Operation(summary = "결제 취소")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.sangdol.roomescape.payment.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.*
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.client.CancelDetailDeserializer
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
data class PaymentGatewayResponse(
|
||||||
|
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 card: CardDetailResponse?,
|
||||||
|
val easyPay: EasyPayDetailResponse?,
|
||||||
|
val transfer: TransferDetailResponse?,
|
||||||
|
val requestedAt: OffsetDateTime,
|
||||||
|
val approvedAt: OffsetDateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaymentGatewayCancelResponse(
|
||||||
|
val status: PaymentStatus,
|
||||||
|
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
||||||
|
val cancels: CancelDetail,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardDetailResponse(
|
||||||
|
val issuerCode: CardIssuerCode,
|
||||||
|
val number: String,
|
||||||
|
val amount: Int,
|
||||||
|
val cardType: CardType,
|
||||||
|
val ownerType: CardOwnerType,
|
||||||
|
val isInterestFree: Boolean,
|
||||||
|
val approveNo: String,
|
||||||
|
val installmentPlanMonths: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EasyPayDetailResponse(
|
||||||
|
val provider: EasyPayCompanyCode,
|
||||||
|
val amount: Int,
|
||||||
|
val discountAmount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TransferDetailResponse(
|
||||||
|
val bankCode: BankCode,
|
||||||
|
val settlementStatus: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CancelDetail(
|
||||||
|
val cancelAmount: Int,
|
||||||
|
val cardDiscountAmount: Int,
|
||||||
|
val transferDiscountAmount: Int,
|
||||||
|
val easyPayDiscountAmount: Int,
|
||||||
|
val canceledAt: OffsetDateTime,
|
||||||
|
val cancelReason: String
|
||||||
|
)
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.sangdol.roomescape.payment.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class PaymentResponse(
|
||||||
|
val orderId: String,
|
||||||
|
val totalAmount: Int,
|
||||||
|
val method: String,
|
||||||
|
val status: PaymentStatus,
|
||||||
|
val requestedAt: Instant,
|
||||||
|
val approvedAt: Instant,
|
||||||
|
val detail: PaymentDetailResponse?,
|
||||||
|
val cancel: PaymentCancelDetailResponse?,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class PaymentDetailResponse {
|
||||||
|
data class CardDetailResponse(
|
||||||
|
val type: String = "CARD",
|
||||||
|
val issuerCode: String,
|
||||||
|
val cardType: String,
|
||||||
|
val ownerType: String,
|
||||||
|
val cardNumber: String,
|
||||||
|
val amount: Int,
|
||||||
|
val approvalNumber: String,
|
||||||
|
val installmentPlanMonths: Int,
|
||||||
|
val easypayProviderName: String?,
|
||||||
|
val easypayDiscountAmount: Int?,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
|
||||||
|
data class BankTransferDetailResponse(
|
||||||
|
val type: String = "BANK_TRANSFER",
|
||||||
|
val bankName: String,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
|
||||||
|
data class EasyPayPrepaidDetailResponse(
|
||||||
|
val type: String = "EASYPAY_PREPAID",
|
||||||
|
val providerName: String,
|
||||||
|
val amount: Int,
|
||||||
|
val discountAmount: Int,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PaymentCancelDetailResponse(
|
||||||
|
val cancellationRequestedAt: Instant,
|
||||||
|
val cancellationApprovedAt: Instant?,
|
||||||
|
val cancelReason: String,
|
||||||
|
val canceledBy: Long,
|
||||||
|
)
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.sangdol.roomescape.payment.dto
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class PaymentConfirmRequest(
|
||||||
|
val paymentKey: String,
|
||||||
|
val orderId: String,
|
||||||
|
val amount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaymentCreateResponse(
|
||||||
|
val paymentId: Long,
|
||||||
|
val detailId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaymentCancelRequest(
|
||||||
|
val reservationId: Long,
|
||||||
|
val cancelReason: String,
|
||||||
|
val requestedAt: Instant = Instant.now()
|
||||||
|
)
|
||||||
@ -6,3 +6,9 @@ class PaymentException(
|
|||||||
override val errorCode: PaymentErrorCode,
|
override val errorCode: PaymentErrorCode,
|
||||||
override val message: String = errorCode.message
|
override val message: String = errorCode.message
|
||||||
) : RoomescapeException(errorCode, message)
|
) : RoomescapeException(errorCode, message)
|
||||||
|
|
||||||
|
class ExternalPaymentException(
|
||||||
|
val httpStatusCode: Int,
|
||||||
|
val errorCode: String,
|
||||||
|
override val message: String
|
||||||
|
) : RuntimeException(message)
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.sangdol.roomescape.payment.exception
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonErrorResponse
|
||||||
|
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 PaymentExceptionHandler(
|
||||||
|
private val logMessageConverter: WebLogMessageConverter
|
||||||
|
) {
|
||||||
|
@ExceptionHandler(PaymentException::class)
|
||||||
|
fun handlePaymentException(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
e: PaymentException
|
||||||
|
): ResponseEntity<CommonErrorResponse> {
|
||||||
|
val errorCode = e.errorCode
|
||||||
|
val httpStatus = errorCode.httpStatus
|
||||||
|
val errorResponse = CommonErrorResponse(errorCode, e.message)
|
||||||
|
|
||||||
|
log.warn {
|
||||||
|
logMessageConverter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
|
httpStatus = httpStatus,
|
||||||
|
responseBody = errorResponse,
|
||||||
|
exception = if (e.message == errorCode.message) null else e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity
|
||||||
|
.status(httpStatus.value())
|
||||||
|
.body(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,67 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.client
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonParser
|
|
||||||
import com.fasterxml.jackson.databind.DeserializationContext
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
data class PaymentClientCancelResponse(
|
|
||||||
val status: PaymentStatus,
|
|
||||||
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
|
||||||
val cancels: CancelDetail,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CancelDetail(
|
|
||||||
val cancelAmount: Int,
|
|
||||||
val cardDiscountAmount: Int,
|
|
||||||
val transferDiscountAmount: Int,
|
|
||||||
val easyPayDiscountAmount: Int,
|
|
||||||
val canceledAt: OffsetDateTime,
|
|
||||||
val cancelReason: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun CancelDetail.toEntity(
|
|
||||||
id: Long,
|
|
||||||
paymentId: Long,
|
|
||||||
canceledBy: Long,
|
|
||||||
cancelRequestedAt: Instant
|
|
||||||
) = CanceledPaymentEntity(
|
|
||||||
id = id,
|
|
||||||
canceledAt = this.canceledAt.toInstant(),
|
|
||||||
requestedAt = cancelRequestedAt,
|
|
||||||
paymentId = paymentId,
|
|
||||||
canceledBy = canceledBy,
|
|
||||||
cancelReason = this.cancelReason,
|
|
||||||
cancelAmount = this.cancelAmount,
|
|
||||||
cardDiscountAmount = this.cardDiscountAmount,
|
|
||||||
transferDiscountAmount = this.transferDiscountAmount,
|
|
||||||
easypayDiscountAmount = this.easyPayDiscountAmount
|
|
||||||
)
|
|
||||||
|
|
||||||
class CancelDetailDeserializer : com.fasterxml.jackson.databind.JsonDeserializer<CancelDetail>() {
|
|
||||||
override fun deserialize(
|
|
||||||
p: JsonParser,
|
|
||||||
ctxt: DeserializationContext
|
|
||||||
): CancelDetail? {
|
|
||||||
val node: JsonNode = p.codec.readTree(p) ?: return null
|
|
||||||
|
|
||||||
val targetNode = when {
|
|
||||||
node.isArray && !node.isEmpty -> node[0]
|
|
||||||
node.isObject -> node
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return CancelDetail(
|
|
||||||
cancelAmount = targetNode.get("cancelAmount").asInt(),
|
|
||||||
cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(),
|
|
||||||
transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(),
|
|
||||||
easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(),
|
|
||||||
canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()),
|
|
||||||
cancelReason = targetNode.get("cancelReason").asText()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,9 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.client
|
package com.sangdol.roomescape.payment.infrastructure.client
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
|
||||||
|
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.exception.PaymentException
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
@ -28,7 +31,7 @@ class TosspayClient(
|
|||||||
paymentKey: String,
|
paymentKey: String,
|
||||||
orderId: String,
|
orderId: String,
|
||||||
amount: Int,
|
amount: Int,
|
||||||
): PaymentClientConfirmResponse {
|
): PaymentGatewayResponse {
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
|
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
|
||||||
|
|
||||||
@ -42,7 +45,7 @@ class TosspayClient(
|
|||||||
paymentKey: String,
|
paymentKey: String,
|
||||||
amount: Int,
|
amount: Int,
|
||||||
cancelReason: String
|
cancelReason: String
|
||||||
): PaymentClientCancelResponse {
|
): PaymentGatewayCancelResponse {
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
|
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
|
||||||
|
|
||||||
@ -62,7 +65,7 @@ private class ConfirmClient(
|
|||||||
|
|
||||||
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
|
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
|
||||||
|
|
||||||
fun request(paymentKey: String, orderId: String, amount: Int): PaymentClientConfirmResponse {
|
fun request(paymentKey: String, orderId: String, amount: Int): PaymentGatewayResponse {
|
||||||
val response = client.post()
|
val response = client.post()
|
||||||
.uri(CONFIRM_URI)
|
.uri(CONFIRM_URI)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@ -83,7 +86,7 @@ private class ConfirmClient(
|
|||||||
|
|
||||||
log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" }
|
log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" }
|
||||||
|
|
||||||
return objectMapper.readValue(response, PaymentClientConfirmResponse::class.java)
|
return objectMapper.readValue(response, PaymentGatewayResponse::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,7 +104,7 @@ private class CancelClient(
|
|||||||
paymentKey: String,
|
paymentKey: String,
|
||||||
amount: Int,
|
amount: Int,
|
||||||
cancelReason: String
|
cancelReason: String
|
||||||
): PaymentClientCancelResponse {
|
): PaymentGatewayCancelResponse {
|
||||||
val response = client.post()
|
val response = client.post()
|
||||||
.uri(CANCEL_URI, paymentKey)
|
.uri(CANCEL_URI, paymentKey)
|
||||||
.body(
|
.body(
|
||||||
@ -119,7 +122,7 @@ private class CancelClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" }
|
log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" }
|
||||||
return objectMapper.readValue(response, PaymentClientCancelResponse::class.java)
|
return objectMapper.readValue(response, PaymentGatewayCancelResponse::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,9 +141,20 @@ private class TosspayErrorHandler(
|
|||||||
response: ClientHttpResponse
|
response: ClientHttpResponse
|
||||||
): Nothing {
|
): Nothing {
|
||||||
val requestType: String = paymentRequestType(url)
|
val requestType: String = paymentRequestType(url)
|
||||||
log.warn { "[TosspayClient] $requestType 요청 실패: response: ${parseResponse(response)}" }
|
val errorResponse: TosspayErrorResponse = parseResponse(response)
|
||||||
|
val status = response.statusCode
|
||||||
|
|
||||||
throw PaymentException(paymentErrorCode(response.statusCode))
|
if (status.is5xxServerError) {
|
||||||
|
log.warn { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" }
|
||||||
|
} else {
|
||||||
|
log.info { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ExternalPaymentException(
|
||||||
|
httpStatusCode = status.value(),
|
||||||
|
errorCode = errorResponse.code,
|
||||||
|
message = errorResponse.message
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun paymentRequestType(url: URI): String {
|
private fun paymentRequestType(url: URI): String {
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.sangdol.roomescape.payment.infrastructure.client
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationContext
|
||||||
|
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.sangdol.roomescape.payment.dto.CancelDetail
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class CancelDetailDeserializer : JsonDeserializer<CancelDetail>() {
|
||||||
|
override fun deserialize(
|
||||||
|
p: JsonParser,
|
||||||
|
ctxt: DeserializationContext
|
||||||
|
): CancelDetail? {
|
||||||
|
val node: JsonNode = p.codec.readTree(p) ?: return null
|
||||||
|
|
||||||
|
val targetNode = when {
|
||||||
|
node.isArray && !node.isEmpty -> node[0]
|
||||||
|
node.isObject -> node
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return CancelDetail(
|
||||||
|
cancelAmount = targetNode.get("cancelAmount").asInt(),
|
||||||
|
cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(),
|
||||||
|
transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(),
|
||||||
|
easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(),
|
||||||
|
canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()),
|
||||||
|
cancelReason = targetNode.get("cancelReason").asText()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.persistence
|
package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||||
|
|
||||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.*
|
import com.sangdol.roomescape.payment.business.domain.BankCode
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.CardIssuerCode
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.CardOwnerType
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.CardType
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.persistence
|
package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||||
|
|
||||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
import com.sangdol.roomescape.payment.business.domain.PaymentType
|
||||||
import jakarta.persistence.Entity
|
import jakarta.persistence.Entity
|
||||||
import jakarta.persistence.EnumType
|
import jakarta.persistence.EnumType
|
||||||
import jakarta.persistence.Enumerated
|
import jakarta.persistence.Enumerated
|
||||||
|
|||||||
@ -1,58 +1,29 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.client
|
package com.sangdol.roomescape.payment.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.dto.CancelDetail
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.*
|
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
|
import java.time.Instant
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
data class PaymentClientConfirmResponse(
|
fun PaymentGatewayResponse.toEntity(
|
||||||
val paymentKey: String,
|
|
||||||
val status: PaymentStatus,
|
|
||||||
val totalAmount: Int,
|
|
||||||
val vat: Int,
|
|
||||||
val suppliedAmount: Int,
|
|
||||||
val method: PaymentMethod,
|
|
||||||
val card: CardDetail?,
|
|
||||||
val easyPay: EasyPayDetail?,
|
|
||||||
val transfer: TransferDetail?,
|
|
||||||
val requestedAt: OffsetDateTime,
|
|
||||||
val approvedAt: OffsetDateTime,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toEntity(
|
|
||||||
id: Long,
|
id: Long,
|
||||||
reservationId: Long,
|
reservationId: Long,
|
||||||
orderId: String,
|
|
||||||
paymentType: PaymentType
|
|
||||||
) = PaymentEntity(
|
) = PaymentEntity(
|
||||||
id = id,
|
id = id,
|
||||||
reservationId = reservationId,
|
reservationId = reservationId,
|
||||||
paymentKey = this.paymentKey,
|
paymentKey = this.paymentKey,
|
||||||
orderId = orderId,
|
orderId = this.orderId,
|
||||||
totalAmount = this.totalAmount,
|
totalAmount = this.totalAmount,
|
||||||
requestedAt = this.requestedAt.toInstant(),
|
requestedAt = this.requestedAt.toInstant(),
|
||||||
approvedAt = this.approvedAt.toInstant(),
|
approvedAt = this.approvedAt.toInstant(),
|
||||||
type = paymentType,
|
type = this.type,
|
||||||
method = this.method,
|
method = this.method,
|
||||||
status = this.status,
|
status = this.status,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CardDetail(
|
fun PaymentGatewayResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
||||||
val issuerCode: CardIssuerCode,
|
|
||||||
val number: String,
|
|
||||||
val amount: Int,
|
|
||||||
val cardType: CardType,
|
|
||||||
val ownerType: CardOwnerType,
|
|
||||||
val isInterestFree: Boolean,
|
|
||||||
val approveNo: String,
|
|
||||||
val installmentPlanMonths: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
|
||||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
return PaymentCardDetailEntity(
|
return PaymentCardDetailEntity(
|
||||||
@ -73,13 +44,7 @@ fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class EasyPayDetail(
|
fun PaymentGatewayResponse.toEasypayPrepaidDetailEntity(
|
||||||
val provider: EasyPayCompanyCode,
|
|
||||||
val amount: Int,
|
|
||||||
val discountAmount: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
|
|
||||||
id: Long,
|
id: Long,
|
||||||
paymentId: Long
|
paymentId: Long
|
||||||
): PaymentEasypayPrepaidDetailEntity {
|
): PaymentEasypayPrepaidDetailEntity {
|
||||||
@ -96,12 +61,7 @@ fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TransferDetail(
|
fun PaymentGatewayResponse.toTransferDetailEntity(
|
||||||
val bankCode: BankCode,
|
|
||||||
val settlementStatus: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toTransferDetailEntity(
|
|
||||||
id: Long,
|
id: Long,
|
||||||
paymentId: Long
|
paymentId: Long
|
||||||
): PaymentBankTransferDetailEntity {
|
): PaymentBankTransferDetailEntity {
|
||||||
@ -116,3 +76,21 @@ fun PaymentClientConfirmResponse.toTransferDetailEntity(
|
|||||||
settlementStatus = transferDetail.settlementStatus
|
settlementStatus = transferDetail.settlementStatus
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun CancelDetail.toEntity(
|
||||||
|
id: Long,
|
||||||
|
paymentId: Long,
|
||||||
|
canceledBy: Long,
|
||||||
|
cancelRequestedAt: Instant
|
||||||
|
) = CanceledPaymentEntity(
|
||||||
|
id = id,
|
||||||
|
canceledAt = this.canceledAt.toInstant(),
|
||||||
|
requestedAt = cancelRequestedAt,
|
||||||
|
paymentId = paymentId,
|
||||||
|
canceledBy = canceledBy,
|
||||||
|
cancelReason = this.cancelReason,
|
||||||
|
cancelAmount = this.cancelAmount,
|
||||||
|
cardDiscountAmount = this.cardDiscountAmount,
|
||||||
|
transferDiscountAmount = this.transferDiscountAmount,
|
||||||
|
easypayDiscountAmount = this.easyPayDiscountAmount
|
||||||
|
)
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package com.sangdol.roomescape.payment.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentCancelDetailResponse
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentDetailResponse
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||||
|
|
||||||
|
fun PaymentEntity.toResponse(
|
||||||
|
detail: PaymentDetailResponse?,
|
||||||
|
cancel: PaymentCancelDetailResponse?
|
||||||
|
): PaymentResponse {
|
||||||
|
return PaymentResponse(
|
||||||
|
orderId = this.orderId,
|
||||||
|
totalAmount = this.totalAmount,
|
||||||
|
method = this.method.koreanName,
|
||||||
|
status = this.status,
|
||||||
|
requestedAt = this.requestedAt,
|
||||||
|
approvedAt = this.approvedAt,
|
||||||
|
detail = detail,
|
||||||
|
cancel = cancel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentDetailEntity.toResponse(): PaymentDetailResponse {
|
||||||
|
return when (this) {
|
||||||
|
is PaymentCardDetailEntity -> this.toResponse()
|
||||||
|
is PaymentBankTransferDetailEntity -> this.toResponse()
|
||||||
|
is PaymentEasypayPrepaidDetailEntity -> this.toResponse()
|
||||||
|
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentCardDetailEntity.toResponse(): PaymentDetailResponse.CardDetailResponse {
|
||||||
|
return PaymentDetailResponse.CardDetailResponse(
|
||||||
|
issuerCode = this.issuerCode.koreanName,
|
||||||
|
cardType = this.cardType.koreanName,
|
||||||
|
ownerType = this.ownerType.koreanName,
|
||||||
|
cardNumber = this.cardNumber,
|
||||||
|
amount = this.amount,
|
||||||
|
approvalNumber = this.approvalNumber,
|
||||||
|
installmentPlanMonths = this.installmentPlanMonths,
|
||||||
|
easypayProviderName = this.easypayProviderCode?.koreanName,
|
||||||
|
easypayDiscountAmount = this.easypayDiscountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentBankTransferDetailEntity.toResponse(): PaymentDetailResponse.BankTransferDetailResponse {
|
||||||
|
return PaymentDetailResponse.BankTransferDetailResponse(
|
||||||
|
bankName = this.bankCode.koreanName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentEasypayPrepaidDetailEntity.toResponse(): PaymentDetailResponse.EasyPayPrepaidDetailResponse {
|
||||||
|
return PaymentDetailResponse.EasyPayPrepaidDetailResponse(
|
||||||
|
providerName = this.easypayProviderCode.koreanName,
|
||||||
|
amount = this.amount,
|
||||||
|
discountAmount = this.discountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CanceledPaymentEntity.toResponse(): PaymentCancelDetailResponse {
|
||||||
|
return PaymentCancelDetailResponse(
|
||||||
|
cancellationRequestedAt = this.requestedAt,
|
||||||
|
cancellationApprovedAt = this.canceledAt,
|
||||||
|
cancelReason = this.cancelReason,
|
||||||
|
canceledBy = this.canceledBy
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,6 +5,9 @@ import com.sangdol.roomescape.auth.web.support.User
|
|||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
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.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.*
|
import org.springframework.web.bind.annotation.*
|
||||||
@ -15,12 +18,11 @@ class PaymentController(
|
|||||||
private val paymentService: PaymentService
|
private val paymentService: PaymentService
|
||||||
) : PaymentAPI {
|
) : PaymentAPI {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping("/confirm")
|
||||||
override fun confirmPayment(
|
override fun confirmPayment(
|
||||||
@RequestParam(required = true) reservationId: Long,
|
|
||||||
@Valid @RequestBody request: PaymentConfirmRequest
|
@Valid @RequestBody request: PaymentConfirmRequest
|
||||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>> {
|
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>> {
|
||||||
val response = paymentService.confirm(reservationId, request)
|
val response = paymentService.requestConfirm(request)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
|
||||||
import com.sangdol.roomescape.payment.web.PaymentDetailResponse.*
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
data class PaymentConfirmRequest(
|
|
||||||
val paymentKey: String,
|
|
||||||
val orderId: String,
|
|
||||||
val amount: Int,
|
|
||||||
val paymentType: PaymentType
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentCreateResponse(
|
|
||||||
val paymentId: Long,
|
|
||||||
val detailId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentCancelRequest(
|
|
||||||
val reservationId: Long,
|
|
||||||
val cancelReason: String,
|
|
||||||
val requestedAt: Instant = Instant.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentWithDetailResponse(
|
|
||||||
val orderId: String,
|
|
||||||
val totalAmount: Int,
|
|
||||||
val method: String,
|
|
||||||
val status: PaymentStatus,
|
|
||||||
val requestedAt: Instant,
|
|
||||||
val approvedAt: Instant,
|
|
||||||
val detail: PaymentDetailResponse?,
|
|
||||||
val cancel: PaymentCancelDetailResponse?,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentEntity.toDetailResponse(
|
|
||||||
detail: PaymentDetailResponse?,
|
|
||||||
cancel: PaymentCancelDetailResponse?
|
|
||||||
): PaymentWithDetailResponse {
|
|
||||||
return PaymentWithDetailResponse(
|
|
||||||
orderId = this.orderId,
|
|
||||||
totalAmount = this.totalAmount,
|
|
||||||
method = this.method.koreanName,
|
|
||||||
status = this.status,
|
|
||||||
requestedAt = this.requestedAt,
|
|
||||||
approvedAt = this.approvedAt,
|
|
||||||
detail = detail,
|
|
||||||
cancel = cancel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class PaymentDetailResponse {
|
|
||||||
|
|
||||||
data class CardDetailResponse(
|
|
||||||
val type: String = "CARD",
|
|
||||||
val issuerCode: String,
|
|
||||||
val cardType: String,
|
|
||||||
val ownerType: String,
|
|
||||||
val cardNumber: String,
|
|
||||||
val amount: Int,
|
|
||||||
val approvalNumber: String,
|
|
||||||
val installmentPlanMonths: Int,
|
|
||||||
val easypayProviderName: String?,
|
|
||||||
val easypayDiscountAmount: Int?,
|
|
||||||
) : PaymentDetailResponse()
|
|
||||||
|
|
||||||
data class BankTransferDetailResponse(
|
|
||||||
val type: String = "BANK_TRANSFER",
|
|
||||||
val bankName: String,
|
|
||||||
) : PaymentDetailResponse()
|
|
||||||
|
|
||||||
data class EasyPayPrepaidDetailResponse(
|
|
||||||
val type: String = "EASYPAY_PREPAID",
|
|
||||||
val providerName: String,
|
|
||||||
val amount: Int,
|
|
||||||
val discountAmount: Int,
|
|
||||||
) : PaymentDetailResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentDetailEntity.toPaymentDetailResponse(): PaymentDetailResponse {
|
|
||||||
return when (this) {
|
|
||||||
is PaymentCardDetailEntity -> this.toCardDetailResponse()
|
|
||||||
is PaymentBankTransferDetailEntity -> this.toBankTransferDetailResponse()
|
|
||||||
is PaymentEasypayPrepaidDetailEntity -> this.toEasyPayPrepaidDetailResponse()
|
|
||||||
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentCardDetailEntity.toCardDetailResponse(): CardDetailResponse {
|
|
||||||
return CardDetailResponse(
|
|
||||||
issuerCode = this.issuerCode.koreanName,
|
|
||||||
cardType = this.cardType.koreanName,
|
|
||||||
ownerType = this.ownerType.koreanName,
|
|
||||||
cardNumber = this.cardNumber,
|
|
||||||
amount = this.amount,
|
|
||||||
approvalNumber = this.approvalNumber,
|
|
||||||
installmentPlanMonths = this.installmentPlanMonths,
|
|
||||||
easypayProviderName = this.easypayProviderCode?.koreanName,
|
|
||||||
easypayDiscountAmount = this.easypayDiscountAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentBankTransferDetailEntity.toBankTransferDetailResponse(): BankTransferDetailResponse {
|
|
||||||
return BankTransferDetailResponse(
|
|
||||||
bankName = this.bankCode.koreanName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayPrepaidDetailResponse {
|
|
||||||
return EasyPayPrepaidDetailResponse(
|
|
||||||
providerName = this.easypayProviderCode.koreanName,
|
|
||||||
amount = this.amount,
|
|
||||||
discountAmount = this.discountAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class PaymentCancelDetailResponse(
|
|
||||||
val cancellationRequestedAt: Instant,
|
|
||||||
val cancellationApprovedAt: Instant?,
|
|
||||||
val cancelReason: String,
|
|
||||||
val canceledBy: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun CanceledPaymentEntity.toCancelDetailResponse(): PaymentCancelDetailResponse {
|
|
||||||
return PaymentCancelDetailResponse(
|
|
||||||
cancellationRequestedAt = this.requestedAt,
|
|
||||||
cancellationApprovedAt = this.canceledAt,
|
|
||||||
cancelReason = this.cancelReason,
|
|
||||||
canceledBy = this.canceledBy
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,9 +1,9 @@
|
|||||||
package com.sangdol.roomescape.region.business
|
package com.sangdol.roomescape.region.business
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.region.dto.*
|
||||||
import com.sangdol.roomescape.region.exception.RegionErrorCode
|
import com.sangdol.roomescape.region.exception.RegionErrorCode
|
||||||
import com.sangdol.roomescape.region.exception.RegionException
|
import com.sangdol.roomescape.region.exception.RegionException
|
||||||
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
|
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
|
||||||
import com.sangdol.roomescape.region.web.*
|
|
||||||
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.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|||||||
@ -2,9 +2,9 @@ package com.sangdol.roomescape.region.docs
|
|||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.region.web.RegionCodeResponse
|
import com.sangdol.roomescape.region.dto.RegionCodeResponse
|
||||||
import com.sangdol.roomescape.region.web.SidoListResponse
|
import com.sangdol.roomescape.region.dto.SidoListResponse
|
||||||
import com.sangdol.roomescape.region.web.SigunguListResponse
|
import com.sangdol.roomescape.region.dto.SigunguListResponse
|
||||||
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
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.sangdol.roomescape.region.web
|
package com.sangdol.roomescape.region.dto
|
||||||
|
|
||||||
data class SidoResponse(
|
data class SidoResponse(
|
||||||
val code: String,
|
val code: String,
|
||||||
@ -3,6 +3,9 @@ package com.sangdol.roomescape.region.web
|
|||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.region.business.RegionService
|
import com.sangdol.roomescape.region.business.RegionService
|
||||||
import com.sangdol.roomescape.region.docs.RegionAPI
|
import com.sangdol.roomescape.region.docs.RegionAPI
|
||||||
|
import com.sangdol.roomescape.region.dto.RegionCodeResponse
|
||||||
|
import com.sangdol.roomescape.region.dto.SidoListResponse
|
||||||
|
import com.sangdol.roomescape.region.dto.SigunguListResponse
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
|||||||
@ -3,17 +3,22 @@ package com.sangdol.roomescape.reservation.business
|
|||||||
import com.sangdol.common.persistence.IDGenerator
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
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.web.PaymentWithDetailResponse
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.*
|
||||||
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.infrastructure.persistence.*
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.*
|
||||||
import com.sangdol.roomescape.reservation.web.*
|
import com.sangdol.roomescape.reservation.mapper.toAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.mapper.toEntity
|
||||||
|
import com.sangdol.roomescape.reservation.mapper.toOverviewResponse
|
||||||
|
import com.sangdol.roomescape.reservation.mapper.toStateResponse
|
||||||
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.ScheduleWithThemeAndStoreResponse
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
|
|
||||||
import com.sangdol.roomescape.theme.business.ThemeService
|
import com.sangdol.roomescape.theme.business.ThemeService
|
||||||
import com.sangdol.roomescape.user.business.UserService
|
import com.sangdol.roomescape.user.business.UserService
|
||||||
import com.sangdol.roomescape.user.web.UserContactResponse
|
import com.sangdol.roomescape.user.dto.UserContactResponse
|
||||||
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.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
@ -42,11 +47,18 @@ class ReservationService(
|
|||||||
): PendingReservationCreateResponse {
|
): PendingReservationCreateResponse {
|
||||||
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
|
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
|
||||||
|
|
||||||
validateCanCreate(request)
|
run {
|
||||||
|
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
|
||||||
|
val theme = themeService.findInfoById(schedule.themeId)
|
||||||
|
|
||||||
val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id)
|
reservationValidator.validateCanCreate(schedule, theme, request)
|
||||||
|
}
|
||||||
|
|
||||||
return PendingReservationCreateResponse(reservationRepository.save(reservation).id)
|
val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id).also {
|
||||||
|
reservationRepository.save(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PendingReservationCreateResponse(reservation.id)
|
||||||
.also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
|
.also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,22 +108,30 @@ class ReservationService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ReservationOverviewListResponse(reservations.map {
|
return ReservationOverviewListResponse(reservations.map {
|
||||||
val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId)
|
val response: ScheduleWithThemeAndStoreResponse = scheduleService.findWithThemeAndStore(it.scheduleId)
|
||||||
it.toOverviewResponse(schedule)
|
val schedule = response.schedule
|
||||||
|
|
||||||
|
it.toOverviewResponse(
|
||||||
|
scheduleDate = schedule.date,
|
||||||
|
scheduleStartFrom = schedule.startFrom,
|
||||||
|
scheduleEndAt = schedule.endAt,
|
||||||
|
storeName = response.theme.name,
|
||||||
|
themeName = response.store.name
|
||||||
|
)
|
||||||
}).also {
|
}).also {
|
||||||
log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
|
log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findDetailById(id: Long): ReservationDetailResponse {
|
fun findDetailById(id: Long): ReservationAdditionalResponse {
|
||||||
log.info { "[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)
|
||||||
val paymentDetail: PaymentWithDetailResponse? = paymentService.findDetailByReservationId(id)
|
val paymentDetail: PaymentResponse? = paymentService.findDetailByReservationId(id)
|
||||||
|
|
||||||
return reservation.toReservationDetailRetrieveResponse(
|
return reservation.toAdditionalResponse(
|
||||||
user = user,
|
user = user,
|
||||||
payment = paymentDetail
|
payment = paymentDetail
|
||||||
).also {
|
).also {
|
||||||
@ -119,6 +139,30 @@ class ReservationService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findStatusWithLock(id: Long): ReservationStateResponse {
|
||||||
|
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
|
||||||
|
|
||||||
|
return reservationRepository.findByIdForUpdate(id)?.let {
|
||||||
|
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" }
|
||||||
|
it.toStateResponse()
|
||||||
|
} ?: run {
|
||||||
|
log.warn { "[findStatusWithLock] 예약 LOCK + 상태 조회 실패: reservationId=${id}" }
|
||||||
|
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun markInProgress(reservationId: Long) {
|
||||||
|
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
|
||||||
|
|
||||||
|
findOrThrow(reservationId).apply {
|
||||||
|
this.status = ReservationStatus.PAYMENT_IN_PROGRESS
|
||||||
|
}.also {
|
||||||
|
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun findOrThrow(id: Long): ReservationEntity {
|
private fun findOrThrow(id: Long): ReservationEntity {
|
||||||
log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
||||||
|
|
||||||
@ -149,14 +193,6 @@ class ReservationService(
|
|||||||
status = CanceledReservationStatus.COMPLETED
|
status = CanceledReservationStatus.COMPLETED
|
||||||
).also {
|
).also {
|
||||||
canceledReservationRepository.save(it)
|
canceledReservationRepository.save(it)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateCanCreate(request: PendingReservationCreateRequest) {
|
|
||||||
val schedule = scheduleService.findSummaryWithLock(request.scheduleId)
|
|
||||||
val theme = themeService.findInfoById(schedule.themeId)
|
|
||||||
|
|
||||||
reservationValidator.validateCanCreate(schedule, theme, request)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
package com.sangdol.roomescape.reservation.business
|
package com.sangdol.roomescape.reservation.business
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.KoreaDateTime
|
||||||
|
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.web.PendingReservationCreateRequest
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleSummaryResponse
|
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
||||||
import com.sangdol.roomescape.theme.web.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
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -16,22 +20,35 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
class ReservationValidator {
|
class ReservationValidator {
|
||||||
|
|
||||||
fun validateCanCreate(
|
fun validateCanCreate(
|
||||||
schedule: ScheduleSummaryResponse,
|
schedule: ScheduleStateResponse,
|
||||||
theme: ThemeInfoResponse,
|
theme: ThemeInfoResponse,
|
||||||
request: PendingReservationCreateRequest
|
request: PendingReservationCreateRequest
|
||||||
) {
|
) {
|
||||||
|
validateSchedule(schedule)
|
||||||
|
validateReservationInfo(theme, request)
|
||||||
|
}
|
||||||
|
private fun validateSchedule(schedule: ScheduleStateResponse) {
|
||||||
if (schedule.status != ScheduleStatus.HOLD) {
|
if (schedule.status != ScheduleStatus.HOLD) {
|
||||||
log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
|
log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
|
||||||
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
|
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
||||||
|
val nowDateTime = KoreaDateTime.now()
|
||||||
|
if (scheduleDateTime.isBefore(nowDateTime)) {
|
||||||
|
log.info { "[validateCanCreate] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}" }
|
||||||
|
throw ReservationException(ReservationErrorCode.PAST_SCHEDULE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateReservationInfo(theme: ThemeInfoResponse, request: PendingReservationCreateRequest) {
|
||||||
if (theme.minParticipants > request.participantCount) {
|
if (theme.minParticipants > request.participantCount) {
|
||||||
log.info { "[ReservationValidator.validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
|
log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
|
||||||
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
|
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (theme.maxParticipants < request.participantCount) {
|
if (theme.maxParticipants < request.participantCount) {
|
||||||
log.info { "[ReservationValidator.validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
|
log.info { "[validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
|
||||||
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
|
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,20 +24,28 @@ 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.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
||||||
|
|
||||||
scheduleRepository.releaseExpiredHolds(Instant.now()).also {
|
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
|
||||||
log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" }
|
log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRepository.releaseHeldSchedules(targets).also {
|
||||||
|
log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||||
@Transactional
|
@Transactional
|
||||||
fun processExpiredReservation() {
|
fun processExpiredReservation() {
|
||||||
log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " }
|
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
|
||||||
|
|
||||||
reservationRepository.expirePendingReservations(Instant.now()).also {
|
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
|
||||||
log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" }
|
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
|
||||||
|
}
|
||||||
|
|
||||||
|
reservationRepository.expirePendingReservations(Instant.now(), targets).also {
|
||||||
|
log.info { "[processExpiredReservation] ${it}개의 예약 및 일정 처리 완료" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,11 @@ 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.auth.web.support.UserOnly
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.reservation.web.*
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse
|
||||||
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
|
||||||
@ -47,5 +51,5 @@ interface ReservationAPI {
|
|||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun findDetailById(
|
fun findDetailById(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>>
|
): ResponseEntity<CommonApiResponse<ReservationAdditionalResponse>>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
|
import com.sangdol.roomescape.user.dto.UserContactResponse
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class ReservationOverviewResponse(
|
||||||
|
val id: Long,
|
||||||
|
val storeName: String,
|
||||||
|
val themeName: String,
|
||||||
|
val date: LocalDate,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val endAt: LocalTime,
|
||||||
|
val status: ReservationStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReservationAdditionalResponse(
|
||||||
|
val id: Long,
|
||||||
|
val reserver: ReserverInfo,
|
||||||
|
val user: UserContactResponse,
|
||||||
|
val applicationDateTime: Instant,
|
||||||
|
val payment: PaymentResponse?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReserverInfo(
|
||||||
|
val name: String,
|
||||||
|
val contact: String,
|
||||||
|
val participantCount: Short,
|
||||||
|
val requirement: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReservationOverviewListResponse(
|
||||||
|
val reservations: List<ReservationOverviewResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReservationStateResponse(
|
||||||
|
val id: Long,
|
||||||
|
val scheduleId: Long,
|
||||||
|
val status: ReservationStatus,
|
||||||
|
val createdAt: Instant
|
||||||
|
)
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.dto
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty
|
||||||
|
|
||||||
|
data class ReservationCancelRequest(
|
||||||
|
val cancelReason: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PendingReservationCreateRequest(
|
||||||
|
val scheduleId: Long,
|
||||||
|
@NotEmpty
|
||||||
|
val reserverName: String,
|
||||||
|
@NotEmpty
|
||||||
|
val reserverContact: String,
|
||||||
|
val participantCount: Short,
|
||||||
|
val requirement: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PendingReservationCreateResponse(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
@ -12,6 +12,7 @@ enum class ReservationErrorCode(
|
|||||||
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
|
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
|
||||||
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
|
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
|
||||||
EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."),
|
EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."),
|
||||||
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.")
|
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요."),
|
||||||
|
PAST_SCHEDULE(HttpStatus.BAD_REQUEST, "R006", "지난 일정은 예약할 수 없어요.")
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,5 +31,5 @@ class ReservationEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class ReservationStatus {
|
enum class ReservationStatus {
|
||||||
PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED
|
PENDING, PAYMENT_IN_PROGRESS, CONFIRMED, CANCELED, EXPIRED;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package com.sangdol.roomescape.reservation.infrastructure.persistence
|
package com.sangdol.roomescape.reservation.infrastructure.persistence
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Lock
|
||||||
import org.springframework.data.jpa.repository.Modifying
|
import org.springframework.data.jpa.repository.Modifying
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
import org.springframework.data.repository.query.Param
|
||||||
@ -10,6 +12,24 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
|
|
||||||
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
|
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
|
||||||
|
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
|
||||||
|
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT
|
||||||
|
r.id
|
||||||
|
FROM
|
||||||
|
reservation r
|
||||||
|
JOIN
|
||||||
|
schedule s ON r.schedule_id = s.id AND s.status = 'HOLD'
|
||||||
|
WHERE
|
||||||
|
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
""", nativeQuery = true)
|
||||||
|
fun findAllExpiredReservation(): List<Long>
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@ -23,8 +43,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
|||||||
s.status = 'AVAILABLE',
|
s.status = 'AVAILABLE',
|
||||||
s.hold_expired_at = NULL
|
s.hold_expired_at = NULL
|
||||||
WHERE
|
WHERE
|
||||||
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
r.id IN :reservationIds
|
||||||
""", nativeQuery = true
|
""", nativeQuery = true
|
||||||
)
|
)
|
||||||
fun expirePendingReservations(@Param("now") now: Instant): Int
|
fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationOverviewResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReserverInfo
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
|
import com.sangdol.roomescape.user.dto.UserContactResponse
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
fun PendingReservationCreateRequest.toEntity(id: Long, userId: Long) = ReservationEntity(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
scheduleId = this.scheduleId,
|
||||||
|
reserverName = this.reserverName,
|
||||||
|
reserverContact = this.reserverContact,
|
||||||
|
participantCount = this.participantCount,
|
||||||
|
requirement = this.requirement,
|
||||||
|
status = ReservationStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReservationEntity.toOverviewResponse(
|
||||||
|
scheduleDate: LocalDate,
|
||||||
|
scheduleStartFrom: LocalTime,
|
||||||
|
scheduleEndAt: LocalTime,
|
||||||
|
storeName: String,
|
||||||
|
themeName: String
|
||||||
|
) = ReservationOverviewResponse(
|
||||||
|
id = this.id,
|
||||||
|
storeName = storeName,
|
||||||
|
themeName = themeName,
|
||||||
|
date = scheduleDate,
|
||||||
|
startFrom = scheduleStartFrom,
|
||||||
|
endAt = scheduleEndAt,
|
||||||
|
status = this.status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReservationEntity.toAdditionalResponse(
|
||||||
|
user: UserContactResponse,
|
||||||
|
payment: PaymentResponse?,
|
||||||
|
): ReservationAdditionalResponse {
|
||||||
|
return ReservationAdditionalResponse(
|
||||||
|
id = this.id,
|
||||||
|
reserver = this.toReserverInfo(),
|
||||||
|
user = user,
|
||||||
|
applicationDateTime = this.createdAt,
|
||||||
|
payment = payment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReservationEntity.toReserverInfo() = ReserverInfo(
|
||||||
|
name = this.reserverName,
|
||||||
|
contact = this.reserverContact,
|
||||||
|
participantCount = this.participantCount,
|
||||||
|
requirement = this.requirement
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReservationEntity.toStateResponse() = ReservationStateResponse(
|
||||||
|
id = this.id,
|
||||||
|
scheduleId = this.scheduleId,
|
||||||
|
status = this.status,
|
||||||
|
createdAt = this.createdAt
|
||||||
|
)
|
||||||
@ -5,6 +5,11 @@ import com.sangdol.roomescape.auth.web.support.User
|
|||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.reservation.business.ReservationService
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
import com.sangdol.roomescape.reservation.docs.ReservationAPI
|
import com.sangdol.roomescape.reservation.docs.ReservationAPI
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
@ -56,7 +61,7 @@ class ReservationController(
|
|||||||
@GetMapping("/{id}/detail")
|
@GetMapping("/{id}/detail")
|
||||||
override fun findDetailById(
|
override fun findDetailById(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>> {
|
): ResponseEntity<CommonApiResponse<ReservationAdditionalResponse>> {
|
||||||
val response = reservationService.findDetailById(id)
|
val response = reservationService.findDetailById(id)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
package com.sangdol.roomescape.reservation.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
|
|
||||||
import com.sangdol.roomescape.user.web.UserContactResponse
|
|
||||||
import jakarta.validation.constraints.NotEmpty
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
data class PendingReservationCreateRequest(
|
|
||||||
val scheduleId: Long,
|
|
||||||
@NotEmpty
|
|
||||||
val reserverName: String,
|
|
||||||
@NotEmpty
|
|
||||||
val reserverContact: String,
|
|
||||||
val participantCount: Short,
|
|
||||||
val requirement: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PendingReservationCreateRequest.toEntity(id: Long, userId: Long) = ReservationEntity(
|
|
||||||
id = id,
|
|
||||||
userId = userId,
|
|
||||||
scheduleId = this.scheduleId,
|
|
||||||
reserverName = this.reserverName,
|
|
||||||
reserverContact = this.reserverContact,
|
|
||||||
participantCount = this.participantCount,
|
|
||||||
requirement = this.requirement,
|
|
||||||
status = ReservationStatus.PENDING
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PendingReservationCreateResponse(
|
|
||||||
val id: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReservationOverviewResponse(
|
|
||||||
val id: Long,
|
|
||||||
val storeName: String,
|
|
||||||
val themeName: String,
|
|
||||||
val date: LocalDate,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val status: ReservationStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReservationEntity.toOverviewResponse(
|
|
||||||
schedule: ScheduleOverviewResponse
|
|
||||||
) = ReservationOverviewResponse(
|
|
||||||
id = this.id,
|
|
||||||
storeName = schedule.storeName,
|
|
||||||
themeName = schedule.themeName,
|
|
||||||
date = schedule.date,
|
|
||||||
startFrom = schedule.startFrom,
|
|
||||||
endAt = schedule.endAt,
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReservationOverviewListResponse(
|
|
||||||
val reservations: List<ReservationOverviewResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReserverInfo(
|
|
||||||
val name: String,
|
|
||||||
val contact: String,
|
|
||||||
val participantCount: Short,
|
|
||||||
val requirement: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReservationEntity.toReserverInfo() = ReserverInfo(
|
|
||||||
name = this.reserverName,
|
|
||||||
contact = this.reserverContact,
|
|
||||||
participantCount = this.participantCount,
|
|
||||||
requirement = this.requirement
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReservationDetailResponse(
|
|
||||||
val id: Long,
|
|
||||||
val reserver: ReserverInfo,
|
|
||||||
val user: UserContactResponse,
|
|
||||||
val applicationDateTime: Instant,
|
|
||||||
val payment: PaymentWithDetailResponse?,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReservationEntity.toReservationDetailRetrieveResponse(
|
|
||||||
user: UserContactResponse,
|
|
||||||
payment: PaymentWithDetailResponse?,
|
|
||||||
): ReservationDetailResponse {
|
|
||||||
return ReservationDetailResponse(
|
|
||||||
id = this.id,
|
|
||||||
reserver = this.toReserverInfo(),
|
|
||||||
user = user,
|
|
||||||
applicationDateTime = this.createdAt,
|
|
||||||
payment = payment,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ReservationCancelRequest(
|
|
||||||
val cancelReason: String
|
|
||||||
)
|
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.common.utils.KoreaDate
|
||||||
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.common.types.Auditor
|
||||||
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
|
||||||
|
import com.sangdol.roomescape.schedule.exception.ScheduleException
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
|
import com.sangdol.roomescape.schedule.mapper.toAdminSummaryResponse
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminScheduleService(
|
||||||
|
private val scheduleRepository: ScheduleRepository,
|
||||||
|
private val scheduleValidator: ScheduleValidator,
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val adminService: AdminService
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
|
||||||
|
log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
|
||||||
|
|
||||||
|
val searchDate = date ?: KoreaDate.today()
|
||||||
|
|
||||||
|
val schedules: List<ScheduleOverview> =
|
||||||
|
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate)
|
||||||
|
.filter { (themeId == null) || (it.themeId == themeId) }
|
||||||
|
.sortedBy { it.time }
|
||||||
|
|
||||||
|
return schedules.toAdminSummaryResponse()
|
||||||
|
.also {
|
||||||
|
log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findScheduleAudit(id: Long): AuditingInfo {
|
||||||
|
log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
|
||||||
|
|
||||||
|
val schedule: ScheduleEntity = findOrThrow(id)
|
||||||
|
|
||||||
|
val createdBy: Auditor = adminService.findOperatorOrUnknown(schedule.createdBy)
|
||||||
|
val updatedBy: Auditor = adminService.findOperatorOrUnknown(schedule.updatedBy)
|
||||||
|
|
||||||
|
return AuditingInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy)
|
||||||
|
.also { log.info { "[findDetail] 일정 감사 정보 조회 완료: id=$id" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
|
||||||
|
log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
|
||||||
|
|
||||||
|
scheduleValidator.validateCanCreate(storeId, request)
|
||||||
|
|
||||||
|
val schedule = ScheduleEntityFactory.create(
|
||||||
|
id = idGenerator.create(),
|
||||||
|
date = request.date,
|
||||||
|
time = request.time,
|
||||||
|
storeId = storeId,
|
||||||
|
themeId = request.themeId
|
||||||
|
).also {
|
||||||
|
scheduleRepository.save(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScheduleCreateResponse(schedule.id)
|
||||||
|
.also {
|
||||||
|
log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
|
||||||
|
log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
|
||||||
|
|
||||||
|
if (request.isAllParamsNull()) {
|
||||||
|
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val schedule: ScheduleEntity = findOrThrow(id).also {
|
||||||
|
scheduleValidator.validateCanUpdate(it, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule.modifyIfNotNull(request.time, request.status).also {
|
||||||
|
log.info { "[updateSchedule] 일정 수정 완료: id=$id, request=${request}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteSchedule(id: Long) {
|
||||||
|
log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" }
|
||||||
|
|
||||||
|
val schedule: ScheduleEntity = findOrThrow(id).also {
|
||||||
|
scheduleValidator.validateCanDelete(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRepository.delete(schedule).also {
|
||||||
|
log.info { "[deleteSchedule] 일정 삭제 완료: id=$id" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findOrThrow(id: Long): ScheduleEntity {
|
||||||
|
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
|
||||||
|
|
||||||
|
return scheduleRepository.findByIdOrNull(id)
|
||||||
|
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
|
||||||
|
?: run {
|
||||||
|
log.warn { "[findOrThrow] 일정 조회 실패. id=$id" }
|
||||||
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,21 @@
|
|||||||
package com.sangdol.roomescape.schedule.business
|
package com.sangdol.roomescape.schedule.business
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
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.admin.business.AdminService
|
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
|
||||||
import com.sangdol.roomescape.common.types.Auditor
|
|
||||||
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
|
||||||
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
|
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
|
||||||
import com.sangdol.roomescape.schedule.exception.ScheduleException
|
import com.sangdol.roomescape.schedule.exception.ScheduleException
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
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.schedule.web.*
|
import com.sangdol.roomescape.schedule.mapper.toResponseWithTheme
|
||||||
|
import com.sangdol.roomescape.schedule.mapper.toResponseWithThemeAndStore
|
||||||
|
import com.sangdol.roomescape.schedule.mapper.toStateResponse
|
||||||
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.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
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@ -24,25 +23,11 @@ import java.time.LocalTime
|
|||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Structure:
|
|
||||||
* - Public: 모두가 접근 가능
|
|
||||||
* - User: 회원(로그인된 사용자)가 사용 가능
|
|
||||||
* - All-Admin: 모든 관리자가 사용 가능
|
|
||||||
* - Store-Admin: 매장 관리자만 사용 가능
|
|
||||||
* - Other-Service: 다른 서비스에서 호출하는 메서드
|
|
||||||
* - Common: 공통 메서드
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
class ScheduleService(
|
class ScheduleService(
|
||||||
private val scheduleRepository: ScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val scheduleValidator: ScheduleValidator,
|
private val scheduleValidator: ScheduleValidator
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
private val adminService: AdminService
|
|
||||||
) {
|
) {
|
||||||
// ========================================
|
|
||||||
// Public (인증 불필요)
|
|
||||||
// ========================================
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
|
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
|
||||||
log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
|
log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
|
||||||
@ -59,127 +44,31 @@ 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.toResponse()
|
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}개 일정 조회 완료" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// User (회원 로그인 필요)
|
|
||||||
// ========================================
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun holdSchedule(id: Long) {
|
fun holdSchedule(id: Long) {
|
||||||
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
|
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
|
||||||
val result: Int = scheduleRepository.changeStatus(
|
|
||||||
id = id,
|
val schedule = findForUpdateOrThrow(id).also {
|
||||||
currentStatus = ScheduleStatus.AVAILABLE,
|
scheduleValidator.validateCanHold(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRepository.changeStatus(
|
||||||
|
id = schedule.id,
|
||||||
|
currentStatus = schedule.status,
|
||||||
changeStatus = ScheduleStatus.HOLD
|
changeStatus = ScheduleStatus.HOLD
|
||||||
).also {
|
).also {
|
||||||
log.info { "[holdSchedule] $it 개의 row 변경 완료" }
|
log.info { "[holdSchedule] 일정 Holding 완료: id=$id" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result == 0) {
|
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info { "[holdSchedule] 일정 Holding 완료: id=$id" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// All-Admin (본사, 매장 모두 사용가능)
|
|
||||||
// ========================================
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
|
|
||||||
log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
|
|
||||||
|
|
||||||
val searchDate = date ?: KoreaDate.today()
|
|
||||||
|
|
||||||
val schedules: List<ScheduleOverview> =
|
|
||||||
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate)
|
|
||||||
.filter { (themeId == null) || (it.themeId == themeId) }
|
|
||||||
.sortedBy { it.time }
|
|
||||||
|
|
||||||
return schedules.toAdminSummaryListResponse()
|
|
||||||
.also {
|
|
||||||
log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findScheduleAudit(id: Long): AuditingInfo {
|
fun findStateWithLock(id: Long): ScheduleStateResponse {
|
||||||
log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
|
|
||||||
|
|
||||||
val schedule: ScheduleEntity = findOrThrow(id)
|
|
||||||
|
|
||||||
val createdBy: Auditor = adminService.findOperatorOrUnknown(schedule.createdBy)
|
|
||||||
val updatedBy: Auditor = adminService.findOperatorOrUnknown(schedule.updatedBy)
|
|
||||||
|
|
||||||
return AuditingInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy)
|
|
||||||
.also { log.info { "[findDetail] 일정 감사 정보 조회 완료: id=$id" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Store-Admin (매장 관리자 로그인 필요)
|
|
||||||
// ========================================
|
|
||||||
@Transactional
|
|
||||||
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
|
|
||||||
log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
|
|
||||||
|
|
||||||
scheduleValidator.validateCanCreate(storeId, request)
|
|
||||||
|
|
||||||
val schedule = ScheduleEntityFactory.create(
|
|
||||||
id = idGenerator.create(),
|
|
||||||
date = request.date,
|
|
||||||
time = request.time,
|
|
||||||
storeId = storeId,
|
|
||||||
themeId = request.themeId
|
|
||||||
).also {
|
|
||||||
scheduleRepository.save(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ScheduleCreateResponse(schedule.id)
|
|
||||||
.also {
|
|
||||||
log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
|
|
||||||
log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
|
|
||||||
|
|
||||||
if (request.isAllParamsNull()) {
|
|
||||||
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val schedule: ScheduleEntity = findOrThrow(id).also {
|
|
||||||
scheduleValidator.validateCanUpdate(it, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule.modifyIfNotNull(request.time, request.status).also {
|
|
||||||
log.info { "[updateSchedule] 일정 수정 완료: id=$id, request=${request}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteSchedule(id: Long) {
|
|
||||||
log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" }
|
|
||||||
|
|
||||||
val schedule: ScheduleEntity = findOrThrow(id).also {
|
|
||||||
scheduleValidator.validateCanDelete(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleRepository.delete(schedule).also {
|
|
||||||
log.info { "[deleteSchedule] 일정 삭제 완료: id=$id" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Other-Service (API 없이 다른 서비스에서 호출)
|
|
||||||
// ========================================
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findSummaryWithLock(id: Long): ScheduleSummaryResponse {
|
|
||||||
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
|
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
|
||||||
|
|
||||||
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
|
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
|
||||||
@ -188,20 +77,20 @@ class ScheduleService(
|
|||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
return schedule.toSummaryResponse()
|
return schedule.toStateResponse()
|
||||||
.also {
|
.also {
|
||||||
log.info { "[findDateTimeById] 일정 개요 조회 완료: id=$id" }
|
log.info { "[findDateTimeById] 일정 개요 조회 완료: id=$id" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findScheduleOverviewById(id: Long): ScheduleOverviewResponse {
|
fun findWithThemeAndStore(id: Long): ScheduleWithThemeAndStoreResponse {
|
||||||
val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run {
|
val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run {
|
||||||
log.warn { "[findScheduleOverview] 일정 개요 조회 실패: id=$id" }
|
log.warn { "[findScheduleOverview] 일정 개요 조회 실패: id=$id" }
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
return overview.toOverviewResponse()
|
return overview.toResponseWithThemeAndStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@ -213,16 +102,13 @@ class ScheduleService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
|
||||||
// Common (공통 메서드)
|
log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
|
||||||
// ========================================
|
|
||||||
private fun findOrThrow(id: Long): ScheduleEntity {
|
|
||||||
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
|
|
||||||
|
|
||||||
return scheduleRepository.findByIdOrNull(id)
|
return scheduleRepository.findByIdForUpdate(id)
|
||||||
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
|
?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }
|
||||||
?: run {
|
?: run {
|
||||||
log.warn { "[updateSchedule] 일정 조회 실패. id=$id" }
|
log.warn { "[findForUpdateOrThrow] 일정 조회 실패. id=$id" }
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import com.sangdol.roomescape.schedule.exception.ScheduleException
|
|||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
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.schedule.web.ScheduleCreateRequest
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest
|
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||||
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
|
||||||
@ -22,11 +22,20 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
class ScheduleValidator(
|
class ScheduleValidator(
|
||||||
private val scheduleRepository: ScheduleRepository
|
private val scheduleRepository: ScheduleRepository
|
||||||
) {
|
) {
|
||||||
|
fun validateCanHold(schedule: ScheduleEntity) {
|
||||||
|
if (schedule.status != ScheduleStatus.AVAILABLE) {
|
||||||
|
log.info { "[validateCanHold] HOLD 실패: id=${schedule.id}, status=${schedule.status}" }
|
||||||
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateNotInPast(schedule.date, schedule.time)
|
||||||
|
}
|
||||||
|
|
||||||
fun validateCanDelete(schedule: ScheduleEntity) {
|
fun validateCanDelete(schedule: ScheduleEntity) {
|
||||||
val status: ScheduleStatus = schedule.status
|
val status: ScheduleStatus = schedule.status
|
||||||
|
|
||||||
if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) {
|
if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) {
|
||||||
log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
|
log.info { "[validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,7 +60,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.info {
|
log.info {
|
||||||
"[ScheduleValidator.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)
|
||||||
}
|
}
|
||||||
@ -63,7 +72,7 @@ class ScheduleValidator(
|
|||||||
|
|
||||||
if (inputDateTime.isBefore(now)) {
|
if (inputDateTime.isBefore(now)) {
|
||||||
log.info {
|
log.info {
|
||||||
"[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
|
"[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
|
||||||
}
|
}
|
||||||
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
||||||
}
|
}
|
||||||
@ -73,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.info { "[ScheduleValidator.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import com.sangdol.roomescape.auth.web.support.AdminOnly
|
|||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
import com.sangdol.roomescape.schedule.web.*
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
|
||||||
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
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class AdminScheduleSummaryResponse(
|
||||||
|
val id: Long,
|
||||||
|
val themeName: String,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val endAt: LocalTime,
|
||||||
|
val status: ScheduleStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminScheduleSummaryListResponse(
|
||||||
|
val schedules: List<AdminScheduleSummaryResponse>
|
||||||
|
)
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class ScheduleCreateRequest(
|
||||||
|
val date: LocalDate,
|
||||||
|
val time: LocalTime,
|
||||||
|
val themeId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleCreateResponse(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleUpdateRequest(
|
||||||
|
val time: LocalTime? = null,
|
||||||
|
val status: ScheduleStatus? = null
|
||||||
|
) {
|
||||||
|
fun isAllParamsNull(): Boolean {
|
||||||
|
return time == null && status == null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class ScheduleResponse(
|
||||||
|
val id: Long,
|
||||||
|
val date: LocalDate,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val endAt: LocalTime,
|
||||||
|
val status: ScheduleStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleStateResponse(
|
||||||
|
val date: LocalDate,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val themeId: Long,
|
||||||
|
val status: ScheduleStatus,
|
||||||
|
val holdExpiredAt: Instant? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleThemeInfo(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleStoreInfo(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleWithThemeResponse(
|
||||||
|
val schedule: ScheduleResponse,
|
||||||
|
val theme: ScheduleThemeInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleWithThemeAndStoreResponse(
|
||||||
|
val schedule: ScheduleResponse,
|
||||||
|
val theme: ScheduleThemeInfo,
|
||||||
|
val store: ScheduleStoreInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleWithThemeListResponse(
|
||||||
|
val schedules: List<ScheduleWithThemeResponse>
|
||||||
|
)
|
||||||
@ -126,6 +126,26 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
|
expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
|
||||||
): Int
|
): Int
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id
|
||||||
|
FROM
|
||||||
|
schedule s
|
||||||
|
WHERE
|
||||||
|
s.status = 'HOLD'
|
||||||
|
AND s.hold_expired_at <= :now
|
||||||
|
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
|
||||||
|
""", nativeQuery = true
|
||||||
|
)
|
||||||
|
fun findAllExpiredHeldSchedules(@Param("now") now: Instant): List<Long>
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
|
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
|
||||||
s.holdExpiredAt = NULL
|
s.holdExpiredAt = NULL
|
||||||
WHERE
|
WHERE
|
||||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD
|
s._id IN :scheduleIds
|
||||||
AND s.holdExpiredAt <= :now
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM ReservationEntity r
|
|
||||||
WHERE r.scheduleId = s._id
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun releaseExpiredHolds(@Param("now") now: Instant): Int
|
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryResponse
|
||||||
|
|
||||||
|
fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse(
|
||||||
|
id = this.id,
|
||||||
|
themeName = this.themeName,
|
||||||
|
startFrom = this.time,
|
||||||
|
endAt = this.getEndAt(),
|
||||||
|
status = this.status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ScheduleOverview>.toAdminSummaryResponse() = AdminScheduleSummaryListResponse(
|
||||||
|
this.map { it.toAdminSummaryResponse() }
|
||||||
|
)
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.schedule.dto.*
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
|
|
||||||
|
fun ScheduleEntity.toStateResponse() = ScheduleStateResponse(
|
||||||
|
date = this.date,
|
||||||
|
startFrom = this.time,
|
||||||
|
themeId = this.themeId,
|
||||||
|
status = this.status,
|
||||||
|
holdExpiredAt = this.holdExpiredAt
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ScheduleOverview.toResponseWithThemeAndStore() = ScheduleWithThemeAndStoreResponse(
|
||||||
|
schedule = ScheduleResponse(this.id, this.date, this.time, this.getEndAt(), this.status),
|
||||||
|
theme = ScheduleThemeInfo(this.themeId, this.themeName),
|
||||||
|
store = ScheduleStoreInfo(this.storeId, this.storeName),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ScheduleOverview.toResponseWithTheme() = ScheduleWithThemeResponse(
|
||||||
|
schedule = ScheduleResponse(this.id, this.date, this.time, this.getEndAt(), this.status),
|
||||||
|
theme = ScheduleThemeInfo(this.themeId, this.themeName),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ScheduleOverview>.toResponseWithTheme() = ScheduleWithThemeListResponse(
|
||||||
|
this.map { it.toResponseWithTheme() }
|
||||||
|
)
|
||||||
@ -2,8 +2,13 @@ package com.sangdol.roomescape.schedule.web
|
|||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.schedule.business.AdminScheduleService
|
||||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI
|
import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI
|
||||||
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.format.annotation.DateTimeFormat
|
import org.springframework.format.annotation.DateTimeFormat
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@ -13,7 +18,7 @@ import java.time.LocalDate
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin")
|
@RequestMapping("/admin")
|
||||||
class AdminScheduleController(
|
class AdminScheduleController(
|
||||||
private val scheduleService: ScheduleService,
|
private val adminScheduleService: AdminScheduleService,
|
||||||
) : AdminScheduleAPI {
|
) : AdminScheduleAPI {
|
||||||
@GetMapping("/stores/{storeId}/schedules")
|
@GetMapping("/stores/{storeId}/schedules")
|
||||||
override fun searchSchedules(
|
override fun searchSchedules(
|
||||||
@ -21,7 +26,7 @@ class AdminScheduleController(
|
|||||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
|
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
|
||||||
@RequestParam(required = false) themeId: Long?,
|
@RequestParam(required = false) themeId: Long?,
|
||||||
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> {
|
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> {
|
||||||
val response = scheduleService.searchSchedules(storeId, date, themeId)
|
val response = adminScheduleService.searchSchedules(storeId, date, themeId)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
@ -30,7 +35,7 @@ class AdminScheduleController(
|
|||||||
override fun findScheduleAudit(
|
override fun findScheduleAudit(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<AuditingInfo>> {
|
): ResponseEntity<CommonApiResponse<AuditingInfo>> {
|
||||||
val response = scheduleService.findScheduleAudit(id)
|
val response = adminScheduleService.findScheduleAudit(id)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
@ -40,7 +45,7 @@ class AdminScheduleController(
|
|||||||
@PathVariable("storeId") storeId: Long,
|
@PathVariable("storeId") storeId: Long,
|
||||||
@Valid @RequestBody request: ScheduleCreateRequest
|
@Valid @RequestBody request: ScheduleCreateRequest
|
||||||
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
|
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
|
||||||
val response = scheduleService.createSchedule(storeId, request)
|
val response = adminScheduleService.createSchedule(storeId, request)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
@ -50,7 +55,7 @@ class AdminScheduleController(
|
|||||||
@PathVariable("id") id: Long,
|
@PathVariable("id") id: Long,
|
||||||
@Valid @RequestBody request: ScheduleUpdateRequest
|
@Valid @RequestBody request: ScheduleUpdateRequest
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
scheduleService.updateSchedule(id, request)
|
adminScheduleService.updateSchedule(id, request)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(Unit))
|
return ResponseEntity.ok(CommonApiResponse(Unit))
|
||||||
}
|
}
|
||||||
@ -59,7 +64,7 @@ class AdminScheduleController(
|
|||||||
override fun deleteSchedule(
|
override fun deleteSchedule(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
scheduleService.deleteSchedule(id)
|
adminScheduleService.deleteSchedule(id)
|
||||||
|
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
package com.sangdol.roomescape.schedule.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// All-Admin DTO (본사 + 매장)
|
|
||||||
// ========================================
|
|
||||||
data class AdminScheduleSummaryResponse(
|
|
||||||
val id: Long,
|
|
||||||
val themeName: String,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val status: ScheduleStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse(
|
|
||||||
id = this.id,
|
|
||||||
themeName = this.themeName,
|
|
||||||
startFrom = this.time,
|
|
||||||
endAt = this.getEndAt(),
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminScheduleSummaryListResponse(
|
|
||||||
val schedules: List<AdminScheduleSummaryResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
fun List<ScheduleOverview>.toAdminSummaryListResponse() = AdminScheduleSummaryListResponse(
|
|
||||||
this.map { it.toAdminSummaryResponse() }
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Store Admin DTO (매장)
|
|
||||||
// ========================================
|
|
||||||
data class ScheduleCreateRequest(
|
|
||||||
val date: LocalDate,
|
|
||||||
val time: LocalTime,
|
|
||||||
val themeId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleCreateResponse(
|
|
||||||
val id: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleUpdateRequest(
|
|
||||||
val time: LocalTime? = null,
|
|
||||||
val status: ScheduleStatus? = null
|
|
||||||
) {
|
|
||||||
fun isAllParamsNull(): Boolean {
|
|
||||||
return time == null && status == null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,6 +4,7 @@ import com.sangdol.common.types.web.CommonApiResponse
|
|||||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
import com.sangdol.roomescape.schedule.docs.PublicScheduleAPI
|
import com.sangdol.roomescape.schedule.docs.PublicScheduleAPI
|
||||||
import com.sangdol.roomescape.schedule.docs.UserScheduleAPI
|
import com.sangdol.roomescape.schedule.docs.UserScheduleAPI
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
|
||||||
import org.springframework.format.annotation.DateTimeFormat
|
import org.springframework.format.annotation.DateTimeFormat
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
package com.sangdol.roomescape.schedule.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Public (인증 불필요)
|
|
||||||
// ========================================
|
|
||||||
data class ScheduleWithThemeResponse(
|
|
||||||
val id: Long,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String,
|
|
||||||
val themeDifficulty: Difficulty,
|
|
||||||
val status: ScheduleStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleOverview.toResponse() = ScheduleWithThemeResponse(
|
|
||||||
id = this.id,
|
|
||||||
startFrom = this.time,
|
|
||||||
endAt = this.getEndAt(),
|
|
||||||
themeId = this.themeId,
|
|
||||||
themeName = this.themeName,
|
|
||||||
themeDifficulty = this.themeDifficulty,
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleWithThemeListResponse(
|
|
||||||
val schedules: List<ScheduleWithThemeResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
fun List<ScheduleOverview>.toResponse() = ScheduleWithThemeListResponse(
|
|
||||||
this.map { it.toResponse() }
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Other-Service (API 없이 다른 서비스에서 호출)
|
|
||||||
// ========================================
|
|
||||||
data class ScheduleSummaryResponse(
|
|
||||||
val date: LocalDate,
|
|
||||||
val time: LocalTime,
|
|
||||||
val themeId: Long,
|
|
||||||
val status: ScheduleStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse(
|
|
||||||
date = this.date,
|
|
||||||
time = this.time,
|
|
||||||
themeId = this.themeId,
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleOverviewResponse(
|
|
||||||
val id: Long,
|
|
||||||
val storeId: Long,
|
|
||||||
val storeName: String,
|
|
||||||
val date: LocalDate,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleOverview.toOverviewResponse() = ScheduleOverviewResponse(
|
|
||||||
id = this.id,
|
|
||||||
storeId = this.storeId,
|
|
||||||
storeName = this.storeName,
|
|
||||||
date = this.date,
|
|
||||||
startFrom = this.time,
|
|
||||||
endAt = this.getEndAt(),
|
|
||||||
themeId = this.themeId,
|
|
||||||
themeName = this.themeName,
|
|
||||||
)
|
|
||||||
@ -4,12 +4,20 @@ 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.region.business.RegionService
|
import com.sangdol.roomescape.region.business.RegionService
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreDetailResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreNameListResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreInfoResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
|
||||||
import com.sangdol.roomescape.store.exception.StoreErrorCode
|
import com.sangdol.roomescape.store.exception.StoreErrorCode
|
||||||
import com.sangdol.roomescape.store.exception.StoreException
|
import com.sangdol.roomescape.store.exception.StoreException
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus
|
||||||
import com.sangdol.roomescape.store.web.*
|
import com.sangdol.roomescape.store.mapper.toDetailResponse
|
||||||
|
import com.sangdol.roomescape.store.mapper.toInfoResponse
|
||||||
|
import com.sangdol.roomescape.store.mapper.toSimpleListResponse
|
||||||
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.Service
|
import org.springframework.stereotype.Service
|
||||||
@ -26,7 +34,7 @@ class StoreService(
|
|||||||
private val idGenerator: IDGenerator,
|
private val idGenerator: IDGenerator,
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getDetail(id: Long): DetailStoreResponse {
|
fun getDetail(id: Long): StoreDetailResponse {
|
||||||
log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" }
|
log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" }
|
||||||
|
|
||||||
val store: StoreEntity = findOrThrow(id)
|
val store: StoreEntity = findOrThrow(id)
|
||||||
@ -85,7 +93,7 @@ class StoreService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse {
|
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse {
|
||||||
log.info { "[getAllActiveStores] 전체 매장 조회 시작" }
|
log.info { "[getAllActiveStores] 전체 매장 조회 시작" }
|
||||||
|
|
||||||
val regionCode: String? = when {
|
val regionCode: String? = when {
|
||||||
|
|||||||
@ -3,8 +3,8 @@ package com.sangdol.roomescape.store.business
|
|||||||
import com.sangdol.roomescape.store.exception.StoreErrorCode
|
import com.sangdol.roomescape.store.exception.StoreErrorCode
|
||||||
import com.sangdol.roomescape.store.exception.StoreException
|
import com.sangdol.roomescape.store.exception.StoreException
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
||||||
import com.sangdol.roomescape.store.web.StoreRegisterRequest
|
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
|
||||||
import com.sangdol.roomescape.store.web.StoreUpdateRequest
|
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
|
||||||
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
|
||||||
|
|||||||
@ -5,7 +5,12 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
|||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
|
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
|
||||||
import com.sangdol.roomescape.auth.web.support.AdminOnly
|
import com.sangdol.roomescape.auth.web.support.AdminOnly
|
||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.store.web.*
|
import com.sangdol.roomescape.store.dto.StoreDetailResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreNameListResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreInfoResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
|
||||||
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
|
||||||
@ -21,7 +26,7 @@ interface AdminStoreAPI {
|
|||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun findStoreDetail(
|
fun findStoreDetail(
|
||||||
@PathVariable id: Long
|
@PathVariable id: Long
|
||||||
): ResponseEntity<CommonApiResponse<DetailStoreResponse>>
|
): ResponseEntity<CommonApiResponse<StoreDetailResponse>>
|
||||||
|
|
||||||
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
|
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
|
||||||
@Operation(summary = "매장 등록")
|
@Operation(summary = "매장 등록")
|
||||||
@ -53,7 +58,7 @@ interface PublicStoreAPI {
|
|||||||
fun getStores(
|
fun getStores(
|
||||||
@RequestParam(value = "sido", required = false) sidoCode: String?,
|
@RequestParam(value = "sido", required = false) sidoCode: String?,
|
||||||
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
|
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
|
||||||
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>>
|
): ResponseEntity<CommonApiResponse<StoreNameListResponse>>
|
||||||
|
|
||||||
@Public
|
@Public
|
||||||
@Operation(summary = "특정 매장의 정보 조회")
|
@Operation(summary = "특정 매장의 정보 조회")
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.sangdol.roomescape.store.dto
|
||||||
|
|
||||||
|
data class StoreRegisterRequest(
|
||||||
|
val name: String,
|
||||||
|
val address: String,
|
||||||
|
val contact: String,
|
||||||
|
val businessRegNum: String,
|
||||||
|
val regionCode: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StoreRegisterResponse(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StoreUpdateRequest(
|
||||||
|
val name: String? = null,
|
||||||
|
val address: String? = null,
|
||||||
|
val contact: String? = null,
|
||||||
|
)
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.sangdol.roomescape.store.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.region.dto.RegionInfoResponse
|
||||||
|
|
||||||
|
data class StoreNameResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StoreNameListResponse(
|
||||||
|
val stores: List<StoreNameResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StoreInfoResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val address: String,
|
||||||
|
val contact: String,
|
||||||
|
val businessRegNum: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StoreDetailResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val address: String,
|
||||||
|
val contact: String,
|
||||||
|
val businessRegNum: String,
|
||||||
|
val region: RegionInfoResponse,
|
||||||
|
val audit: AuditingInfo
|
||||||
|
)
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.sangdol.roomescape.store.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.region.dto.RegionInfoResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreDetailResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreInfoResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreNameListResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreNameResponse
|
||||||
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
||||||
|
|
||||||
|
fun StoreEntity.toInfoResponse() = StoreInfoResponse(
|
||||||
|
id = this.id,
|
||||||
|
name = this.name,
|
||||||
|
address = this.address,
|
||||||
|
contact = this.contact,
|
||||||
|
businessRegNum = this.businessRegNum
|
||||||
|
)
|
||||||
|
|
||||||
|
fun StoreEntity.toDetailResponse(
|
||||||
|
region: RegionInfoResponse,
|
||||||
|
audit: AuditingInfo
|
||||||
|
) = StoreDetailResponse(
|
||||||
|
id = this.id,
|
||||||
|
name = this.name,
|
||||||
|
address = this.address,
|
||||||
|
contact = this.contact,
|
||||||
|
businessRegNum = this.businessRegNum,
|
||||||
|
region = region,
|
||||||
|
audit = audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<StoreEntity>.toSimpleListResponse() = StoreNameListResponse(
|
||||||
|
stores = this.map { StoreNameResponse(id = it.id, name = it.name) }
|
||||||
|
)
|
||||||
@ -3,6 +3,10 @@ package com.sangdol.roomescape.store.web
|
|||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.store.business.StoreService
|
import com.sangdol.roomescape.store.business.StoreService
|
||||||
import com.sangdol.roomescape.store.docs.AdminStoreAPI
|
import com.sangdol.roomescape.store.docs.AdminStoreAPI
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreDetailResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
@ -16,8 +20,8 @@ class AdminStoreController(
|
|||||||
@GetMapping("/{id}/detail")
|
@GetMapping("/{id}/detail")
|
||||||
override fun findStoreDetail(
|
override fun findStoreDetail(
|
||||||
@PathVariable id: Long
|
@PathVariable id: Long
|
||||||
): ResponseEntity<CommonApiResponse<DetailStoreResponse>> {
|
): ResponseEntity<CommonApiResponse<StoreDetailResponse>> {
|
||||||
val response: DetailStoreResponse = storeService.getDetail(id)
|
val response: StoreDetailResponse = storeService.getDetail(id)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
package com.sangdol.roomescape.store.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
|
||||||
import com.sangdol.roomescape.region.web.RegionInfoResponse
|
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
|
||||||
|
|
||||||
data class StoreRegisterRequest(
|
|
||||||
val name: String,
|
|
||||||
val address: String,
|
|
||||||
val contact: String,
|
|
||||||
val businessRegNum: String,
|
|
||||||
val regionCode: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class StoreRegisterResponse(
|
|
||||||
val id: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class StoreUpdateRequest(
|
|
||||||
val name: String? = null,
|
|
||||||
val address: String? = null,
|
|
||||||
val contact: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DetailStoreResponse(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val address: String,
|
|
||||||
val contact: String,
|
|
||||||
val businessRegNum: String,
|
|
||||||
val region: RegionInfoResponse,
|
|
||||||
val audit: AuditingInfo
|
|
||||||
)
|
|
||||||
|
|
||||||
fun StoreEntity.toDetailResponse(
|
|
||||||
region: RegionInfoResponse,
|
|
||||||
audit: AuditingInfo
|
|
||||||
) = DetailStoreResponse(
|
|
||||||
id = this.id,
|
|
||||||
name = this.name,
|
|
||||||
address = this.address,
|
|
||||||
contact = this.contact,
|
|
||||||
businessRegNum = this.businessRegNum,
|
|
||||||
region = region,
|
|
||||||
audit = audit,
|
|
||||||
)
|
|
||||||
@ -3,6 +3,8 @@ package com.sangdol.roomescape.store.web
|
|||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.store.business.StoreService
|
import com.sangdol.roomescape.store.business.StoreService
|
||||||
import com.sangdol.roomescape.store.docs.PublicStoreAPI
|
import com.sangdol.roomescape.store.docs.PublicStoreAPI
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreNameListResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreInfoResponse
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
@ -18,7 +20,7 @@ class StoreController(
|
|||||||
override fun getStores(
|
override fun getStores(
|
||||||
@RequestParam(value = "sido", required = false) sidoCode: String?,
|
@RequestParam(value = "sido", required = false) sidoCode: String?,
|
||||||
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
|
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
|
||||||
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>> {
|
): ResponseEntity<CommonApiResponse<StoreNameListResponse>> {
|
||||||
val response = storeService.getAllActiveStores(sidoCode, sigunguCode)
|
val response = storeService.getAllActiveStores(sidoCode, sigunguCode)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
package com.sangdol.roomescape.store.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
|
||||||
|
|
||||||
data class SimpleStoreResponse(
|
|
||||||
val id: Long,
|
|
||||||
val name: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SimpleStoreListResponse(
|
|
||||||
val stores: List<SimpleStoreResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
fun List<StoreEntity>.toSimpleListResponse() = SimpleStoreListResponse(
|
|
||||||
stores = this.map { SimpleStoreResponse(id = it.id, name = it.name) }
|
|
||||||
)
|
|
||||||
|
|
||||||
data class StoreInfoResponse(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val address: String,
|
|
||||||
val contact: String,
|
|
||||||
val businessRegNum: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun StoreEntity.toInfoResponse() = StoreInfoResponse(
|
|
||||||
id = this.id,
|
|
||||||
name = this.name,
|
|
||||||
address = this.address,
|
|
||||||
contact = this.contact,
|
|
||||||
businessRegNum = this.businessRegNum
|
|
||||||
)
|
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
package com.sangdol.roomescape.theme.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeDetailResponse
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeNameListResponse
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeCreateRequest
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeCreateResponse
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
|
||||||
|
import com.sangdol.roomescape.theme.exception.ThemeErrorCode
|
||||||
|
import com.sangdol.roomescape.theme.exception.ThemeException
|
||||||
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
|
import com.sangdol.roomescape.theme.mapper.toDetailResponse
|
||||||
|
import com.sangdol.roomescape.theme.mapper.toSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.theme.mapper.toEntity
|
||||||
|
import com.sangdol.roomescape.theme.mapper.toNameListResponse
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminThemeService(
|
||||||
|
private val themeRepository: ThemeRepository,
|
||||||
|
private val themeValidator: ThemeValidator,
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val adminService: AdminService
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findThemeSummaries(): ThemeSummaryListResponse {
|
||||||
|
log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
|
||||||
|
|
||||||
|
return themeRepository.findAll()
|
||||||
|
.toSummaryListResponse()
|
||||||
|
.also { log.info { "[findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findThemeDetail(id: Long): ThemeDetailResponse {
|
||||||
|
log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
|
val createdBy = adminService.findOperatorOrUnknown(theme.createdBy)
|
||||||
|
val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy)
|
||||||
|
val audit = AuditingInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy)
|
||||||
|
|
||||||
|
return theme.toDetailResponse(audit)
|
||||||
|
.also { log.info { "[findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findActiveThemes(): ThemeNameListResponse {
|
||||||
|
log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
|
||||||
|
|
||||||
|
return themeRepository.findActiveThemes()
|
||||||
|
.toNameListResponse()
|
||||||
|
.also {
|
||||||
|
log.info { "[findActiveThemes] ${it.themes.size}개 테마 조회 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
|
||||||
|
log.info { "[createTheme] 테마 생성 시작: name=${request.name}" }
|
||||||
|
|
||||||
|
themeValidator.validateCanCreate(request)
|
||||||
|
|
||||||
|
val theme: ThemeEntity = request.toEntity(id = idGenerator.create())
|
||||||
|
.also { themeRepository.save(it) }
|
||||||
|
|
||||||
|
return ThemeCreateResponse(theme.id).also {
|
||||||
|
log.info { "[createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteTheme(id: Long) {
|
||||||
|
log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" }
|
||||||
|
|
||||||
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
|
themeRepository.delete(theme).also {
|
||||||
|
log.info { "[deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
|
||||||
|
log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
|
||||||
|
|
||||||
|
if (request.isAllParamsNull()) {
|
||||||
|
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
themeValidator.validateCanUpdate(request)
|
||||||
|
|
||||||
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
|
theme.modifyIfNotNull(
|
||||||
|
request.name,
|
||||||
|
request.description,
|
||||||
|
request.thumbnailUrl,
|
||||||
|
request.difficulty,
|
||||||
|
request.price,
|
||||||
|
request.minParticipants,
|
||||||
|
request.maxParticipants,
|
||||||
|
request.availableMinutes,
|
||||||
|
request.expectedMinutesFrom,
|
||||||
|
request.expectedMinutesTo,
|
||||||
|
request.isActive,
|
||||||
|
).also {
|
||||||
|
log.info { "[updateTheme] 테마 수정 완료: id=$id, request=${request}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findOrThrow(id: Long): ThemeEntity {
|
||||||
|
log.info { "[findOrThrow] 테마 조회 시작: id=$id" }
|
||||||
|
|
||||||
|
return themeRepository.findByIdOrNull(id)
|
||||||
|
?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }
|
||||||
|
?: run {
|
||||||
|
log.warn { "[findOrThrow] 테마 조회 실패: id=$id" }
|
||||||
|
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package com.sangdol.roomescape.theme.business
|
|
||||||
|
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.temporal.TemporalAdjusters
|
|
||||||
|
|
||||||
object DateUtils {
|
|
||||||
fun getSundayOfPreviousWeek(date: LocalDate): LocalDate = date
|
|
||||||
.minusWeeks(1)
|
|
||||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
|
|
||||||
}
|
|
||||||
@ -1,44 +1,38 @@
|
|||||||
package com.sangdol.roomescape.theme.business
|
package com.sangdol.roomescape.theme.business
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.common.utils.KoreaDate
|
import com.sangdol.common.utils.KoreaDate
|
||||||
import com.sangdol.roomescape.admin.business.AdminService
|
import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
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.exception.ThemeException
|
import com.sangdol.roomescape.theme.exception.ThemeException
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import com.sangdol.roomescape.theme.web.*
|
import com.sangdol.roomescape.theme.mapper.toInfoResponse
|
||||||
|
import com.sangdol.roomescape.theme.mapper.toListResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
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
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.temporal.TemporalAdjusters
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Structure:
|
|
||||||
* - Public: 모두가 접근 가능한 메서드
|
|
||||||
* - Store Admin: 매장 관리자가 사용하는 메서드
|
|
||||||
* - HQ Admin: 본사 관리자가 사용하는 메서드
|
|
||||||
* - Common: 공통 메서드
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
class ThemeService(
|
class ThemeService(
|
||||||
private val themeRepository: ThemeRepository,
|
private val themeRepository: ThemeRepository
|
||||||
private val themeValidator: ThemeValidator,
|
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
private val adminService: AdminService
|
|
||||||
) {
|
) {
|
||||||
// ========================================
|
|
||||||
// Public (인증 불필요)
|
|
||||||
// ========================================
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findInfoById(id: Long): ThemeInfoResponse {
|
fun findInfoById(id: Long): ThemeInfoResponse {
|
||||||
log.info { "[findInfoById] 테마 조회 시작: id=$id" }
|
log.info { "[findInfoById] 테마 조회 시작: id=$id" }
|
||||||
|
|
||||||
return findOrThrow(id).toInfoResponse()
|
val theme = themeRepository.findByIdOrNull(id) ?: run {
|
||||||
|
log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
|
||||||
|
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme.toInfoResponse()
|
||||||
.also { log.info { "[findInfoById] 테마 조회 완료: id=$id" } }
|
.also { log.info { "[findInfoById] 테마 조회 완료: id=$id" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,115 +48,11 @@ class ThemeService(
|
|||||||
.also {
|
.also {
|
||||||
log.info { "[findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" }
|
log.info { "[findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// HQ Admin (본사)
|
|
||||||
// ========================================
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAdminThemes(): AdminThemeSummaryListResponse {
|
|
||||||
log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
|
|
||||||
|
|
||||||
return themeRepository.findAll()
|
|
||||||
.toAdminThemeSummaryListResponse()
|
|
||||||
.also { log.info { "[findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findAdminThemeDetail(id: Long): AdminThemeDetailResponse {
|
|
||||||
log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
|
|
||||||
|
|
||||||
val theme: ThemeEntity = findOrThrow(id)
|
|
||||||
|
|
||||||
val createdBy = adminService.findOperatorOrUnknown(theme.createdBy)
|
|
||||||
val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy)
|
|
||||||
val audit = AuditingInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy)
|
|
||||||
|
|
||||||
return theme.toAdminThemeDetailResponse(audit)
|
|
||||||
.also { log.info { "[findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
|
|
||||||
log.info { "[createTheme] 테마 생성 시작: name=${request.name}" }
|
|
||||||
|
|
||||||
themeValidator.validateCanCreate(request)
|
|
||||||
|
|
||||||
val theme: ThemeEntity = request.toEntity(id = idGenerator.create())
|
|
||||||
.also { themeRepository.save(it) }
|
|
||||||
|
|
||||||
return ThemeCreateResponse(theme.id).also {
|
|
||||||
log.info { "[createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteTheme(id: Long) {
|
|
||||||
log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" }
|
|
||||||
|
|
||||||
val theme: ThemeEntity = findOrThrow(id)
|
|
||||||
|
|
||||||
themeRepository.delete(theme).also {
|
|
||||||
log.info { "[deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
|
|
||||||
log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
|
|
||||||
|
|
||||||
if (request.isAllParamsNull()) {
|
|
||||||
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
themeValidator.validateCanUpdate(request)
|
|
||||||
|
|
||||||
val theme: ThemeEntity = findOrThrow(id)
|
|
||||||
|
|
||||||
theme.modifyIfNotNull(
|
|
||||||
request.name,
|
|
||||||
request.description,
|
|
||||||
request.thumbnailUrl,
|
|
||||||
request.difficulty,
|
|
||||||
request.price,
|
|
||||||
request.minParticipants,
|
|
||||||
request.maxParticipants,
|
|
||||||
request.availableMinutes,
|
|
||||||
request.expectedMinutesFrom,
|
|
||||||
request.expectedMinutesTo,
|
|
||||||
request.isActive,
|
|
||||||
).also {
|
|
||||||
log.info { "[updateTheme] 테마 수정 완료: id=$id, request=${request}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Store Admin (매장)
|
|
||||||
// ========================================
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun findActiveThemes(): SimpleActiveThemeListResponse {
|
|
||||||
log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
|
|
||||||
|
|
||||||
return themeRepository.findActiveThemes()
|
|
||||||
.toSimpleActiveThemeResponse()
|
|
||||||
.also {
|
|
||||||
log.info { "[findActiveThemes] ${it.themes.size}개 테마 조회 완료" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Common (공통 메서드)
|
|
||||||
// ========================================
|
|
||||||
private fun findOrThrow(id: Long): ThemeEntity {
|
|
||||||
log.info { "[findOrThrow] 테마 조회 시작: id=$id" }
|
|
||||||
|
|
||||||
return themeRepository.findByIdOrNull(id)
|
|
||||||
?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }
|
|
||||||
?: run {
|
|
||||||
log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
|
|
||||||
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object DateUtils {
|
||||||
|
fun getSundayOfPreviousWeek(date: LocalDate): LocalDate = date
|
||||||
|
.minusWeeks(1)
|
||||||
|
.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
|
||||||
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ package com.sangdol.roomescape.theme.business
|
|||||||
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.ThemeRepository
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import com.sangdol.roomescape.theme.web.ThemeCreateRequest
|
import com.sangdol.roomescape.theme.dto.ThemeCreateRequest
|
||||||
import com.sangdol.roomescape.theme.web.ThemeUpdateRequest
|
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
|
||||||
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
|
||||||
|
|||||||
@ -5,7 +5,14 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
|||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
|
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
|
||||||
import com.sangdol.roomescape.auth.web.support.AdminOnly
|
import com.sangdol.roomescape.auth.web.support.AdminOnly
|
||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.theme.web.*
|
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.ThemeInfoListResponse
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
|
||||||
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
|
||||||
@ -20,12 +27,12 @@ interface AdminThemeAPI {
|
|||||||
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY)
|
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY)
|
||||||
@Operation(summary = "모든 테마 조회")
|
@Operation(summary = "모든 테마 조회")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>>
|
fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
|
||||||
|
|
||||||
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL)
|
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL)
|
||||||
@Operation(summary = "테마 상세 조회")
|
@Operation(summary = "테마 상세 조회")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>>
|
fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<ThemeDetailResponse>>
|
||||||
|
|
||||||
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
|
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
|
||||||
@Operation(summary = "테마 추가")
|
@Operation(summary = "테마 추가")
|
||||||
@ -48,7 +55,7 @@ interface AdminThemeAPI {
|
|||||||
@AdminOnly(privilege = Privilege.READ_SUMMARY)
|
@AdminOnly(privilege = Privilege.READ_SUMMARY)
|
||||||
@Operation(summary = "현재 활성화 상태인 테마 ID + 이름 목록 조회")
|
@Operation(summary = "현재 활성화 상태인 테마 ID + 이름 목록 조회")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun getActiveThemes(): ResponseEntity<CommonApiResponse<SimpleActiveThemeListResponse>>
|
fun getActiveThemes(): ResponseEntity<CommonApiResponse<ThemeNameListResponse>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicThemeAPI {
|
interface PublicThemeAPI {
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.sangdol.roomescape.theme.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
|
||||||
|
|
||||||
|
data class ThemeSummaryResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val difficulty: Difficulty,
|
||||||
|
val price: Int,
|
||||||
|
val isActive: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeSummaryListResponse(
|
||||||
|
val themes: List<ThemeSummaryResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeDetailResponse(
|
||||||
|
val theme: ThemeInfoResponse,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val audit: AuditingInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeNameResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeNameListResponse(
|
||||||
|
val themes: List<ThemeNameResponse>
|
||||||
|
)
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.sangdol.roomescape.theme.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
|
||||||
|
|
||||||
|
data class ThemeCreateRequest(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val thumbnailUrl: String,
|
||||||
|
val difficulty: Difficulty,
|
||||||
|
val price: Int,
|
||||||
|
val minParticipants: Short,
|
||||||
|
val maxParticipants: Short,
|
||||||
|
val availableMinutes: Short,
|
||||||
|
val expectedMinutesFrom: Short,
|
||||||
|
val expectedMinutesTo: Short,
|
||||||
|
val isActive: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeCreateResponse(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeUpdateRequest(
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val thumbnailUrl: String? = null,
|
||||||
|
val difficulty: Difficulty? = null,
|
||||||
|
val price: Int? = null,
|
||||||
|
val minParticipants: Short? = null,
|
||||||
|
val maxParticipants: Short? = null,
|
||||||
|
val availableMinutes: Short? = null,
|
||||||
|
val expectedMinutesFrom: Short? = null,
|
||||||
|
val expectedMinutesTo: Short? = null,
|
||||||
|
val isActive: Boolean? = null,
|
||||||
|
) {
|
||||||
|
fun isAllParamsNull(): Boolean {
|
||||||
|
return name == null &&
|
||||||
|
description == null &&
|
||||||
|
thumbnailUrl == null &&
|
||||||
|
difficulty == null &&
|
||||||
|
price == null &&
|
||||||
|
minParticipants == null &&
|
||||||
|
maxParticipants == null &&
|
||||||
|
availableMinutes == null &&
|
||||||
|
expectedMinutesFrom == null &&
|
||||||
|
expectedMinutesTo == null &&
|
||||||
|
isActive == null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.sangdol.roomescape.theme.dto
|
||||||
|
|
||||||
|
data class ThemeInfoResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val thumbnailUrl: String,
|
||||||
|
val description: String,
|
||||||
|
val difficulty: String,
|
||||||
|
val price: Int,
|
||||||
|
val minParticipants: Short,
|
||||||
|
val maxParticipants: Short,
|
||||||
|
val availableMinutes: Short,
|
||||||
|
val expectedMinutesFrom: Short,
|
||||||
|
val expectedMinutesTo: Short
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ThemeInfoListResponse(
|
||||||
|
val themes: List<ThemeInfoResponse>
|
||||||
|
)
|
||||||
@ -32,10 +32,9 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
|
|||||||
AND (s.date BETWEEN :startFrom AND :endAt)
|
AND (s.date BETWEEN :startFrom AND :endAt)
|
||||||
GROUP BY
|
GROUP BY
|
||||||
s.theme_id
|
s.theme_id
|
||||||
ORDER BY
|
|
||||||
reservation_count desc
|
|
||||||
LIMIT :count
|
LIMIT :count
|
||||||
) ranked_themes ON t.id = ranked_themes.theme_id
|
) ranked_themes ON t.id = ranked_themes.theme_id
|
||||||
|
ORDER BY ranked_themes.reservation_count DESC, t.id ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true
|
nativeQuery = true
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.sangdol.roomescape.theme.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.theme.dto.*
|
||||||
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
|
|
||||||
|
fun ThemeCreateRequest.toEntity(id: Long) = ThemeEntity(
|
||||||
|
id = id,
|
||||||
|
name = this.name,
|
||||||
|
description = this.description,
|
||||||
|
thumbnailUrl = this.thumbnailUrl,
|
||||||
|
difficulty = this.difficulty,
|
||||||
|
price = this.price,
|
||||||
|
minParticipants = this.minParticipants,
|
||||||
|
maxParticipants = this.maxParticipants,
|
||||||
|
availableMinutes = this.availableMinutes,
|
||||||
|
expectedMinutesFrom = this.expectedMinutesFrom,
|
||||||
|
expectedMinutesTo = this.expectedMinutesTo,
|
||||||
|
isActive = this.isActive
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThemeEntity.toSummaryResponse() = ThemeSummaryResponse(
|
||||||
|
id = this.id,
|
||||||
|
name = this.name,
|
||||||
|
difficulty = this.difficulty,
|
||||||
|
price = this.price,
|
||||||
|
isActive = this.isActive
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThemeEntity.toDetailResponse(audit: AuditingInfo) =
|
||||||
|
ThemeDetailResponse(
|
||||||
|
theme = this.toInfoResponse(),
|
||||||
|
isActive = this.isActive,
|
||||||
|
audit = audit
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThemeEntity.toNameResponse() = ThemeNameResponse(
|
||||||
|
id = this.id,
|
||||||
|
name = this.name
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ThemeEntity>.toSummaryListResponse() = ThemeSummaryListResponse(
|
||||||
|
themes = this.map { it.toSummaryResponse() }
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ThemeEntity>.toNameListResponse() = ThemeNameListResponse(
|
||||||
|
themes = this.map { it.toNameResponse() }
|
||||||
|
)
|
||||||
@ -1,23 +1,11 @@
|
|||||||
package com.sangdol.roomescape.theme.web
|
package com.sangdol.roomescape.theme.mapper
|
||||||
|
|
||||||
import com.sangdol.roomescape.theme.business.domain.ThemeInfo
|
import com.sangdol.roomescape.theme.business.domain.ThemeInfo
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse
|
||||||
|
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
|
|
||||||
data class ThemeInfoResponse(
|
fun ThemeInfo.toResponse() = ThemeInfoResponse(
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val thumbnailUrl: String,
|
|
||||||
val description: String,
|
|
||||||
val difficulty: String,
|
|
||||||
val price: Int,
|
|
||||||
val minParticipants: Short,
|
|
||||||
val maxParticipants: Short,
|
|
||||||
val availableMinutes: Short,
|
|
||||||
val expectedMinutesFrom: Short,
|
|
||||||
val expectedMinutesTo: Short
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ThemeInfo.toInfoResponse() = ThemeInfoResponse(
|
|
||||||
id = this.id,
|
id = this.id,
|
||||||
name = this.name,
|
name = this.name,
|
||||||
thumbnailUrl = this.thumbnailUrl,
|
thumbnailUrl = this.thumbnailUrl,
|
||||||
@ -45,10 +33,8 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
|
|||||||
expectedMinutesTo = this.expectedMinutesTo
|
expectedMinutesTo = this.expectedMinutesTo
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ThemeInfoListResponse(
|
fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
|
||||||
val themes: List<ThemeInfoResponse>
|
themes = this.map { it.toResponse() }
|
||||||
)
|
)
|
||||||
|
|
||||||
fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
|
|
||||||
themes = this.map { it.toInfoResponse() }
|
|
||||||
)
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
package com.sangdol.roomescape.theme.web
|
package com.sangdol.roomescape.theme.web
|
||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.theme.business.ThemeService
|
import com.sangdol.roomescape.theme.business.AdminThemeService
|
||||||
import com.sangdol.roomescape.theme.docs.AdminThemeAPI
|
import com.sangdol.roomescape.theme.docs.AdminThemeAPI
|
||||||
|
import com.sangdol.roomescape.theme.dto.*
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
@ -10,19 +11,19 @@ import java.net.URI
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
class AdminThemeController(
|
class AdminThemeController(
|
||||||
private val themeService: ThemeService,
|
private val adminThemeService: AdminThemeService,
|
||||||
) : AdminThemeAPI {
|
) : AdminThemeAPI {
|
||||||
|
|
||||||
@GetMapping("/admin/themes")
|
@GetMapping("/admin/themes")
|
||||||
override fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>> {
|
override fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>> {
|
||||||
val response = themeService.findAdminThemes()
|
val response = adminThemeService.findThemeSummaries()
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin/themes/{id}")
|
@GetMapping("/admin/themes/{id}")
|
||||||
override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>> {
|
override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeDetailResponse>> {
|
||||||
val response = themeService.findAdminThemeDetail(id)
|
val response = adminThemeService.findThemeDetail(id)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
@ -31,7 +32,7 @@ class AdminThemeController(
|
|||||||
override fun createTheme(
|
override fun createTheme(
|
||||||
@Valid @RequestBody themeCreateRequest: ThemeCreateRequest
|
@Valid @RequestBody themeCreateRequest: ThemeCreateRequest
|
||||||
): ResponseEntity<CommonApiResponse<ThemeCreateResponse>> {
|
): ResponseEntity<CommonApiResponse<ThemeCreateResponse>> {
|
||||||
val response = themeService.createTheme(themeCreateRequest)
|
val response = adminThemeService.createTheme(themeCreateRequest)
|
||||||
|
|
||||||
return ResponseEntity.created(URI.create("/admin/themes/${response.id}"))
|
return ResponseEntity.created(URI.create("/admin/themes/${response.id}"))
|
||||||
.body(CommonApiResponse(response))
|
.body(CommonApiResponse(response))
|
||||||
@ -39,7 +40,7 @@ class AdminThemeController(
|
|||||||
|
|
||||||
@DeleteMapping("/admin/themes/{id}")
|
@DeleteMapping("/admin/themes/{id}")
|
||||||
override fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> {
|
override fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
themeService.deleteTheme(id)
|
adminThemeService.deleteTheme(id)
|
||||||
|
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
@ -49,14 +50,14 @@ class AdminThemeController(
|
|||||||
@PathVariable id: Long,
|
@PathVariable id: Long,
|
||||||
@Valid @RequestBody request: ThemeUpdateRequest
|
@Valid @RequestBody request: ThemeUpdateRequest
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
themeService.updateTheme(id, request)
|
adminThemeService.updateTheme(id, request)
|
||||||
|
|
||||||
return ResponseEntity.ok().build()
|
return ResponseEntity.ok().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin/themes/active")
|
@GetMapping("/admin/themes/active")
|
||||||
override fun getActiveThemes(): ResponseEntity<CommonApiResponse<SimpleActiveThemeListResponse>> {
|
override fun getActiveThemes(): ResponseEntity<CommonApiResponse<ThemeNameListResponse>> {
|
||||||
val response = themeService.findActiveThemes()
|
val response = adminThemeService.findActiveThemes()
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user