generated from pricelees/issue-pr-template
[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 #57
@ -1,6 +1,5 @@
|
||||
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.ErrorCode
|
||||
import com.sangdol.common.types.exception.RoomescapeException
|
||||
@ -31,7 +30,7 @@ class GlobalExceptionHandler(
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
logException(servletRequest, httpStatus, errorResponse, e)
|
||||
log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||
|
||||
return ResponseEntity
|
||||
.status(httpStatus.value())
|
||||
@ -57,7 +56,7 @@ class GlobalExceptionHandler(
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
logException(servletRequest, httpStatus, errorResponse, e)
|
||||
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||
|
||||
return ResponseEntity
|
||||
.status(httpStatus.value())
|
||||
@ -75,30 +74,26 @@ class GlobalExceptionHandler(
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
logException(servletRequest, httpStatus, errorResponse, e)
|
||||
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
|
||||
|
||||
return ResponseEntity
|
||||
.status(httpStatus.value())
|
||||
.body(errorResponse)
|
||||
}
|
||||
|
||||
private fun logException(
|
||||
private fun convertExceptionLogMessage(
|
||||
servletRequest: HttpServletRequest,
|
||||
httpStatus: HttpStatus,
|
||||
errorResponse: CommonErrorResponse,
|
||||
exception: Exception
|
||||
) {
|
||||
val type = if (httpStatus.isClientError()) LogType.APPLICATION_FAILURE else LogType.UNHANDLED_EXCEPTION
|
||||
): String {
|
||||
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
|
||||
|
||||
val logMessage = messageConverter.convertToResponseMessage(
|
||||
type = type,
|
||||
return messageConverter.convertToErrorResponseMessage(
|
||||
servletRequest = servletRequest,
|
||||
httpStatusCode = httpStatus.value(),
|
||||
httpStatus = httpStatus,
|
||||
responseBody = errorResponse,
|
||||
exception = actualException
|
||||
)
|
||||
|
||||
log.warn { logMessage }
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.sangdol.common.web.support.log
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sangdol.common.log.constant.LogType
|
||||
import com.sangdol.common.types.web.HttpStatus
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
|
||||
class WebLogMessageConverter(
|
||||
@ -49,4 +50,19 @@ class WebLogMessageConverter(
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
paymentType: PaymentType;
|
||||
}
|
||||
|
||||
export interface PaymentCancelRequest {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import type { Difficulty } from '@_api/theme/themeTypes';
|
||||
|
||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||
|
||||
export const ScheduleStatus = {
|
||||
@ -40,16 +38,35 @@ export interface AdminScheduleSummaryListResponse {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id: string,
|
||||
startFrom: string,
|
||||
endAt: string,
|
||||
themeId: string,
|
||||
themeName: string,
|
||||
themeDifficulty: Difficulty,
|
||||
status: ScheduleStatus
|
||||
schedule: ScheduleResponse,
|
||||
theme: ScheduleThemeInfo
|
||||
}
|
||||
|
||||
export interface ScheduleWithThemeListResponse {
|
||||
schedules: ScheduleWithThemeResponse[];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import {isLoginRequiredError} from '@_api/apiClient';
|
||||
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
||||
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
|
||||
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||||
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
||||
import {getStores} from '@_api/store/storeAPI';
|
||||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||||
import {fetchThemeById} from '@_api/theme/themeAPI';
|
||||
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
|
||||
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
|
||||
import { type ReservationData } from '@_api/reservation/reservationTypes';
|
||||
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
||||
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
||||
import { getStores } from '@_api/store/storeAPI';
|
||||
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
|
||||
import { fetchThemeById } from '@_api/theme/themeAPI';
|
||||
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useLocation, useNavigate} from 'react-router-dom';
|
||||
import {type ReservationData} from '@_api/reservation/reservationTypes';
|
||||
import {formatDate} from 'src/util/DateTimeFormatter';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { formatDate } from 'src/util/DateTimeFormatter';
|
||||
|
||||
const ReservationStep1Page: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
|
||||
fetchSchedules(selectedStore.id, dateStr)
|
||||
.then(res => {
|
||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||
const key = schedule.themeName;
|
||||
const key = schedule.theme.name;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(schedule);
|
||||
return acc;
|
||||
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
|
||||
const handleConfirmReservation = () => {
|
||||
if (!selectedSchedule) return;
|
||||
|
||||
holdSchedule(selectedSchedule.id)
|
||||
holdSchedule(selectedSchedule.schedule.id)
|
||||
.then(() => {
|
||||
fetchThemeById(selectedSchedule.themeId).then(res => {
|
||||
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
||||
const reservationData: ReservationData = {
|
||||
scheduleId: selectedSchedule.id,
|
||||
scheduleId: selectedSchedule.schedule.id,
|
||||
store: {
|
||||
id: selectedStore!.id,
|
||||
name: selectedStore!.name,
|
||||
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
|
||||
maxParticipants: res.maxParticipants,
|
||||
},
|
||||
date: selectedDate.toLocaleDateString('en-CA'),
|
||||
startFrom: selectedSchedule.startFrom,
|
||||
endAt: selectedSchedule.endAt,
|
||||
startFrom: selectedSchedule.schedule.startFrom,
|
||||
endAt: selectedSchedule.schedule.endAt,
|
||||
};
|
||||
navigate('/reservation/form', {state: reservationData});
|
||||
}).catch(handleError);
|
||||
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
|
||||
<h3>3. 시간 선택</h3>
|
||||
<div className="schedule-list">
|
||||
{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 className="theme-header">
|
||||
<h4>{themeName}</h4>
|
||||
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
||||
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
||||
className="theme-detail-button">상세보기
|
||||
</button>
|
||||
</div>
|
||||
<div className="time-slots">
|
||||
{schedules.map(schedule => (
|
||||
{scheduleAndTheme.map(schedule => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||
key={schedule.schedule.id}
|
||||
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||
>
|
||||
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
||||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||||
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
||||
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
|
||||
<div className="modal-section modal-info-grid">
|
||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
||||
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
||||
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
||||
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { isLoginRequiredError } from '@_api/apiClient';
|
||||
import { confirmPayment } from '@_api/payment/paymentAPI';
|
||||
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes';
|
||||
import { confirm } from '@_api/order/orderAPI';
|
||||
import type { BookingErrorResponse } from '@_api/order/orderTypes';
|
||||
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
||||
import { confirmReservation } from '@_api/reservation/reservationAPI';
|
||||
import '@_css/reservation-v2-1.css';
|
||||
import type { AxiosError } from 'axios';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
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 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(() => {
|
||||
if (!reservationId) {
|
||||
alert('잘못된 접근입니다.');
|
||||
@ -66,7 +56,7 @@ const ReservationStep2Page: React.FC = () => {
|
||||
|
||||
const generateRandomString = () =>
|
||||
crypto.randomUUID().replace(/-/g, '');
|
||||
|
||||
|
||||
|
||||
paymentWidgetRef.current.requestPayment({
|
||||
orderId: generateRandomString(),
|
||||
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
|
||||
paymentKey: data.paymentKey,
|
||||
orderId: data.orderId,
|
||||
amount: totalPrice,
|
||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||
};
|
||||
|
||||
confirmPayment(reservationId, paymentData)
|
||||
.then(() => {
|
||||
return confirmReservation(reservationId);
|
||||
})
|
||||
confirm(reservationId, paymentData)
|
||||
.then(() => {
|
||||
alert('결제가 완료되었어요!');
|
||||
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) => {
|
||||
console.error("Payment request error:", error);
|
||||
alert("결제 요청 중 오류가 발생했습니다.");
|
||||
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package com.sangdol.roomescape.admin.business
|
||||
|
||||
import com.sangdol.roomescape.admin.business.dto.AdminLoginCredentials
|
||||
import com.sangdol.roomescape.admin.business.dto.toCredentials
|
||||
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
|
||||
import com.sangdol.roomescape.admin.mapper.toCredentials
|
||||
import com.sangdol.roomescape.admin.exception.AdminErrorCode
|
||||
import com.sangdol.roomescape.admin.exception.AdminException
|
||||
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.AdminType
|
||||
import com.sangdol.roomescape.auth.web.LoginCredentials
|
||||
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.dto.LoginCredentials
|
||||
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||
|
||||
data class AdminLoginCredentials(
|
||||
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(
|
||||
override val accessToken: String,
|
||||
override val name: String,
|
||||
val type: AdminType,
|
||||
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
|
||||
|
||||
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.AuthException
|
||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||
import com.sangdol.roomescape.auth.web.*
|
||||
import com.sangdol.roomescape.user.business.UserService
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
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.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||
import com.sangdol.roomescape.auth.web.LoginContext
|
||||
import com.sangdol.roomescape.auth.web.PrincipalType
|
||||
import com.sangdol.roomescape.auth.dto.LoginContext
|
||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@ -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
|
||||
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.auth.web.LoginRequest
|
||||
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||
import com.sangdol.roomescape.auth.web.support.Public
|
||||
import com.sangdol.roomescape.auth.web.support.User
|
||||
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
|
||||
|
||||
enum class PrincipalType {
|
||||
USER, ADMIN
|
||||
}
|
||||
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||
|
||||
data class LoginContext(
|
||||
val ipAddress: String,
|
||||
val userAgent: String,
|
||||
)
|
||||
|
||||
fun HttpServletRequest.toLoginContext() = LoginContext(
|
||||
ipAddress = this.remoteAddr,
|
||||
userAgent = this.getHeader("User-Agent")
|
||||
)
|
||||
|
||||
data class LoginRequest(
|
||||
val account: String,
|
||||
val password: String,
|
||||
@ -1,7 +1,7 @@
|
||||
package com.sangdol.roomescape.auth.infrastructure.persistence
|
||||
|
||||
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 org.springframework.data.annotation.CreatedDate
|
||||
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.roomescape.auth.business.AuthService
|
||||
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.common.types.CurrentUserContext
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
@ -36,3 +39,8 @@ class AuthController(
|
||||
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
|
||||
|
||||
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.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.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.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
@ -24,33 +25,54 @@ class PaymentService(
|
||||
private val paymentWriter: PaymentWriter,
|
||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||
) {
|
||||
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse {
|
||||
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm(
|
||||
paymentKey = request.paymentKey,
|
||||
orderId = request.orderId,
|
||||
amount = request.amount,
|
||||
)
|
||||
fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse {
|
||||
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
||||
try {
|
||||
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
|
||||
log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" }
|
||||
}
|
||||
} 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 payment: PaymentEntity = paymentWriter.createPayment(
|
||||
reservationId = reservationId,
|
||||
orderId = request.orderId,
|
||||
paymentType = request.paymentType,
|
||||
paymentClientConfirmResponse = clientConfirmResponse
|
||||
)
|
||||
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
|
||||
val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) {
|
||||
"${errorCode.message}(${e.message})"
|
||||
} else {
|
||||
errorCode.message
|
||||
}
|
||||
|
||||
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
|
||||
} ?: run {
|
||||
log.warn { "[confirm] 결제 확정 중 예상치 못한 null 반환" }
|
||||
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
throw PaymentException(errorCode, message)
|
||||
}
|
||||
else -> {
|
||||
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) {
|
||||
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
|
||||
|
||||
val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel(
|
||||
val clientCancelResponse: PaymentGatewayCancelResponse = paymentClient.cancel(
|
||||
paymentKey = payment.paymentKey,
|
||||
amount = payment.totalAmount,
|
||||
cancelReason = request.cancelReason
|
||||
@ -69,16 +91,16 @@ class PaymentService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? {
|
||||
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
|
||||
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
|
||||
|
||||
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
|
||||
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
|
||||
val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) }
|
||||
|
||||
return payment?.toDetailResponse(
|
||||
detail = paymentDetail?.toPaymentDetailResponse(),
|
||||
cancel = cancelDetail?.toCancelDetailResponse()
|
||||
return payment?.toResponse(
|
||||
detail = paymentDetail?.toResponse(),
|
||||
cancel = cancelDetail?.toResponse()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,13 @@ package com.sangdol.roomescape.payment.business
|
||||
import com.sangdol.common.persistence.IDGenerator
|
||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.client.*
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
|
||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||
import com.sangdol.roomescape.payment.mapper.toCardDetailEntity
|
||||
import com.sangdol.roomescape.payment.mapper.toEasypayPrepaidDetailEntity
|
||||
import com.sangdol.roomescape.payment.mapper.toEntity
|
||||
import com.sangdol.roomescape.payment.mapper.toTransferDetailEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
@ -24,35 +28,31 @@ class PaymentWriter(
|
||||
|
||||
fun createPayment(
|
||||
reservationId: Long,
|
||||
orderId: String,
|
||||
paymentType: PaymentType,
|
||||
paymentClientConfirmResponse: PaymentClientConfirmResponse
|
||||
paymentGatewayResponse: PaymentGatewayResponse
|
||||
): PaymentEntity {
|
||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
|
||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentGatewayResponse.paymentKey}" }
|
||||
|
||||
return paymentClientConfirmResponse.toEntity(
|
||||
id = idGenerator.create(), reservationId, orderId, paymentType
|
||||
).also {
|
||||
return paymentGatewayResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also {
|
||||
paymentRepository.save(it)
|
||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
fun createDetail(
|
||||
paymentResponse: PaymentClientConfirmResponse,
|
||||
paymentGatewayResponse: PaymentGatewayResponse,
|
||||
paymentId: Long,
|
||||
): PaymentDetailEntity {
|
||||
val method: PaymentMethod = paymentResponse.method
|
||||
val method: PaymentMethod = paymentGatewayResponse.method
|
||||
val id = idGenerator.create()
|
||||
|
||||
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) {
|
||||
return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
||||
if (method == PaymentMethod.EASY_PAY && paymentGatewayResponse.card == null) {
|
||||
return paymentDetailRepository.save(paymentGatewayResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
||||
}
|
||||
if (paymentResponse.card != null) {
|
||||
return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId))
|
||||
if (paymentGatewayResponse.card != null) {
|
||||
return paymentDetailRepository.save(paymentGatewayResponse.toCardDetailEntity(id, paymentId))
|
||||
}
|
||||
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||
}
|
||||
@ -61,7 +61,7 @@ class PaymentWriter(
|
||||
userId: Long,
|
||||
payment: PaymentEntity,
|
||||
requestedAt: Instant,
|
||||
cancelResponse: PaymentClientCancelResponse
|
||||
cancelResponse: PaymentGatewayCancelResponse
|
||||
): CanceledPaymentEntity {
|
||||
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.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.UserOnly
|
||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||
import com.sangdol.roomescape.payment.web.PaymentCancelRequest
|
||||
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest
|
||||
import com.sangdol.roomescape.payment.web.PaymentCreateResponse
|
||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
|
||||
interface PaymentAPI {
|
||||
|
||||
@ -21,9 +20,8 @@ interface PaymentAPI {
|
||||
@Operation(summary = "결제 승인")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun confirmPayment(
|
||||
@RequestParam(required = true) reservationId: Long,
|
||||
@Valid @RequestBody request: PaymentConfirmRequest
|
||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>>
|
||||
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>>
|
||||
|
||||
@Operation(summary = "결제 취소")
|
||||
@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 message: String = 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
|
||||
|
||||
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.PaymentException
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
@ -28,7 +31,7 @@ class TosspayClient(
|
||||
paymentKey: String,
|
||||
orderId: String,
|
||||
amount: Int,
|
||||
): PaymentClientConfirmResponse {
|
||||
): PaymentGatewayResponse {
|
||||
val startTime = System.currentTimeMillis()
|
||||
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
|
||||
|
||||
@ -42,7 +45,7 @@ class TosspayClient(
|
||||
paymentKey: String,
|
||||
amount: Int,
|
||||
cancelReason: String
|
||||
): PaymentClientCancelResponse {
|
||||
): PaymentGatewayCancelResponse {
|
||||
val startTime = System.currentTimeMillis()
|
||||
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
|
||||
|
||||
@ -62,7 +65,7 @@ private class ConfirmClient(
|
||||
|
||||
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()
|
||||
.uri(CONFIRM_URI)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@ -83,7 +86,7 @@ private class ConfirmClient(
|
||||
|
||||
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,
|
||||
amount: Int,
|
||||
cancelReason: String
|
||||
): PaymentClientCancelResponse {
|
||||
): PaymentGatewayCancelResponse {
|
||||
val response = client.post()
|
||||
.uri(CANCEL_URI, paymentKey)
|
||||
.body(
|
||||
@ -119,7 +122,7 @@ private class CancelClient(
|
||||
}
|
||||
|
||||
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
|
||||
): Nothing {
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
|
||||
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.*
|
||||
|
||||
@Entity
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||
|
||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||
import com.sangdol.roomescape.payment.business.domain.PaymentType
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
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.PaymentException
|
||||
import com.sangdol.roomescape.payment.infrastructure.common.*
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
|
||||
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
|
||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||
import java.time.Instant
|
||||
|
||||
data class PaymentClientConfirmResponse(
|
||||
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(
|
||||
fun PaymentGatewayResponse.toEntity(
|
||||
id: Long,
|
||||
reservationId: Long,
|
||||
orderId: String,
|
||||
paymentType: PaymentType
|
||||
) = PaymentEntity(
|
||||
id = id,
|
||||
reservationId = reservationId,
|
||||
paymentKey = this.paymentKey,
|
||||
orderId = orderId,
|
||||
orderId = this.orderId,
|
||||
totalAmount = this.totalAmount,
|
||||
requestedAt = this.requestedAt.toInstant(),
|
||||
approvedAt = this.approvedAt.toInstant(),
|
||||
type = paymentType,
|
||||
type = this.type,
|
||||
method = this.method,
|
||||
status = this.status,
|
||||
)
|
||||
|
||||
data class CardDetail(
|
||||
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 {
|
||||
fun PaymentGatewayResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||
|
||||
return PaymentCardDetailEntity(
|
||||
@ -73,13 +44,7 @@ fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long):
|
||||
)
|
||||
}
|
||||
|
||||
data class EasyPayDetail(
|
||||
val provider: EasyPayCompanyCode,
|
||||
val amount: Int,
|
||||
val discountAmount: Int,
|
||||
)
|
||||
|
||||
fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
|
||||
fun PaymentGatewayResponse.toEasypayPrepaidDetailEntity(
|
||||
id: Long,
|
||||
paymentId: Long
|
||||
): PaymentEasypayPrepaidDetailEntity {
|
||||
@ -96,12 +61,7 @@ fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
|
||||
)
|
||||
}
|
||||
|
||||
data class TransferDetail(
|
||||
val bankCode: BankCode,
|
||||
val settlementStatus: String,
|
||||
)
|
||||
|
||||
fun PaymentClientConfirmResponse.toTransferDetailEntity(
|
||||
fun PaymentGatewayResponse.toTransferDetailEntity(
|
||||
id: Long,
|
||||
paymentId: Long
|
||||
): PaymentBankTransferDetailEntity {
|
||||
@ -116,3 +76,21 @@ fun PaymentClientConfirmResponse.toTransferDetailEntity(
|
||||
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.payment.business.PaymentService
|
||||
import com.sangdol.roomescape.payment.docs.PaymentAPI
|
||||
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
@ -15,12 +18,11 @@ class PaymentController(
|
||||
private val paymentService: PaymentService
|
||||
) : PaymentAPI {
|
||||
|
||||
@PostMapping
|
||||
@PostMapping("/confirm")
|
||||
override fun confirmPayment(
|
||||
@RequestParam(required = true) reservationId: Long,
|
||||
@Valid @RequestBody request: PaymentConfirmRequest
|
||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>> {
|
||||
val response = paymentService.confirm(reservationId, request)
|
||||
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>> {
|
||||
val response = paymentService.requestConfirm(request)
|
||||
|
||||
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
|
||||
|
||||
import com.sangdol.roomescape.region.dto.*
|
||||
import com.sangdol.roomescape.region.exception.RegionErrorCode
|
||||
import com.sangdol.roomescape.region.exception.RegionException
|
||||
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.KotlinLogging
|
||||
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.roomescape.auth.web.support.Public
|
||||
import com.sangdol.roomescape.region.web.RegionCodeResponse
|
||||
import com.sangdol.roomescape.region.web.SidoListResponse
|
||||
import com.sangdol.roomescape.region.web.SigunguListResponse
|
||||
import com.sangdol.roomescape.region.dto.RegionCodeResponse
|
||||
import com.sangdol.roomescape.region.dto.SidoListResponse
|
||||
import com.sangdol.roomescape.region.dto.SigunguListResponse
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
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(
|
||||
val code: String,
|
||||
@ -3,6 +3,9 @@ package com.sangdol.roomescape.region.web
|
||||
import com.sangdol.common.types.web.CommonApiResponse
|
||||
import com.sangdol.roomescape.region.business.RegionService
|
||||
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.web.bind.annotation.GetMapping
|
||||
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.roomescape.common.types.CurrentUserContext
|
||||
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.ReservationException
|
||||
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.dto.ScheduleStateResponse
|
||||
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
|
||||
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.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.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@ -42,11 +47,18 @@ class ReservationService(
|
||||
): PendingReservationCreateResponse {
|
||||
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}" } }
|
||||
}
|
||||
|
||||
@ -96,22 +108,30 @@ class ReservationService(
|
||||
)
|
||||
|
||||
return ReservationOverviewListResponse(reservations.map {
|
||||
val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId)
|
||||
it.toOverviewResponse(schedule)
|
||||
val response: ScheduleWithThemeAndStoreResponse = scheduleService.findWithThemeAndStore(it.scheduleId)
|
||||
val schedule = response.schedule
|
||||
|
||||
it.toOverviewResponse(
|
||||
scheduleDate = schedule.date,
|
||||
scheduleStartFrom = schedule.startFrom,
|
||||
scheduleEndAt = schedule.endAt,
|
||||
storeName = response.theme.name,
|
||||
themeName = response.store.name
|
||||
)
|
||||
}).also {
|
||||
log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findDetailById(id: Long): ReservationDetailResponse {
|
||||
fun findDetailById(id: Long): ReservationAdditionalResponse {
|
||||
log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
|
||||
|
||||
val reservation: ReservationEntity = findOrThrow(id)
|
||||
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,
|
||||
payment = paymentDetail
|
||||
).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 {
|
||||
log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
||||
|
||||
@ -149,14 +193,6 @@ class ReservationService(
|
||||
status = CanceledReservationStatus.COMPLETED
|
||||
).also {
|
||||
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
|
||||
|
||||
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.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.web.ScheduleSummaryResponse
|
||||
import com.sangdol.roomescape.theme.web.ThemeInfoResponse
|
||||
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -16,22 +20,35 @@ private val log: KLogger = KotlinLogging.logger {}
|
||||
class ReservationValidator {
|
||||
|
||||
fun validateCanCreate(
|
||||
schedule: ScheduleSummaryResponse,
|
||||
schedule: ScheduleStateResponse,
|
||||
theme: ThemeInfoResponse,
|
||||
request: PendingReservationCreateRequest
|
||||
) {
|
||||
validateSchedule(schedule)
|
||||
validateReservationInfo(theme, request)
|
||||
}
|
||||
private fun validateSchedule(schedule: ScheduleStateResponse) {
|
||||
if (schedule.status != ScheduleStatus.HOLD) {
|
||||
log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
|
||||
log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,20 +24,28 @@ class IncompletedReservationScheduler(
|
||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||
@Transactional
|
||||
fun processExpiredHoldSchedule() {
|
||||
log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
||||
log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
||||
|
||||
scheduleRepository.releaseExpiredHolds(Instant.now()).also {
|
||||
log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" }
|
||||
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
|
||||
log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
|
||||
}
|
||||
|
||||
scheduleRepository.releaseHeldSchedules(targets).also {
|
||||
log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" }
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||
@Transactional
|
||||
fun processExpiredReservation() {
|
||||
log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " }
|
||||
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
|
||||
|
||||
reservationRepository.expirePendingReservations(Instant.now()).also {
|
||||
log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" }
|
||||
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
|
||||
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.UserOnly
|
||||
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.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
@ -47,5 +51,5 @@ interface ReservationAPI {
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun findDetailById(
|
||||
@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", "예약을 취소할 수 있는 권한이 없어요."),
|
||||
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
|
||||
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 {
|
||||
PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED
|
||||
PENDING, PAYMENT_IN_PROGRESS, CONFIRMED, CANCELED, EXPIRED;
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.sangdol.roomescape.reservation.infrastructure.persistence
|
||||
|
||||
import jakarta.persistence.LockModeType
|
||||
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.Query
|
||||
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>
|
||||
|
||||
@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
|
||||
@Query(
|
||||
"""
|
||||
@ -23,8 +43,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
||||
s.status = 'AVAILABLE',
|
||||
s.hold_expired_at = NULL
|
||||
WHERE
|
||||
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
|
||||
r.id IN :reservationIds
|
||||
""", 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.reservation.business.ReservationService
|
||||
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 org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
@ -56,7 +61,7 @@ class ReservationController(
|
||||
@GetMapping("/{id}/detail")
|
||||
override fun findDetailById(
|
||||
@PathVariable("id") id: Long
|
||||
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>> {
|
||||
): ResponseEntity<CommonApiResponse<ReservationAdditionalResponse>> {
|
||||
val response = reservationService.findDetailById(id)
|
||||
|
||||
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
|
||||
|
||||
import com.sangdol.common.persistence.IDGenerator
|
||||
import com.sangdol.common.utils.KoreaDate
|
||||
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.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.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.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.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDate
|
||||
@ -24,25 +23,11 @@ import java.time.LocalTime
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* Structure:
|
||||
* - Public: 모두가 접근 가능
|
||||
* - User: 회원(로그인된 사용자)가 사용 가능
|
||||
* - All-Admin: 모든 관리자가 사용 가능
|
||||
* - Store-Admin: 매장 관리자만 사용 가능
|
||||
* - Other-Service: 다른 서비스에서 호출하는 메서드
|
||||
* - Common: 공통 메서드
|
||||
*/
|
||||
@Service
|
||||
class ScheduleService(
|
||||
private val scheduleRepository: ScheduleRepository,
|
||||
private val scheduleValidator: ScheduleValidator,
|
||||
private val idGenerator: IDGenerator,
|
||||
private val adminService: AdminService
|
||||
private val scheduleValidator: ScheduleValidator
|
||||
) {
|
||||
// ========================================
|
||||
// Public (인증 불필요)
|
||||
// ========================================
|
||||
@Transactional(readOnly = true)
|
||||
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
|
||||
log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
|
||||
@ -59,127 +44,31 @@ class ScheduleService(
|
||||
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
|
||||
.filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
|
||||
|
||||
return schedules.toResponse()
|
||||
return schedules.toResponseWithTheme()
|
||||
.also {
|
||||
log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date 인 ${it.schedules.size}개 일정 조회 완료" }
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// User (회원 로그인 필요)
|
||||
// ========================================
|
||||
@Transactional
|
||||
fun holdSchedule(id: Long) {
|
||||
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
|
||||
val result: Int = scheduleRepository.changeStatus(
|
||||
id = id,
|
||||
currentStatus = ScheduleStatus.AVAILABLE,
|
||||
|
||||
val schedule = findForUpdateOrThrow(id).also {
|
||||
scheduleValidator.validateCanHold(it)
|
||||
}
|
||||
|
||||
scheduleRepository.changeStatus(
|
||||
id = schedule.id,
|
||||
currentStatus = schedule.status,
|
||||
changeStatus = ScheduleStatus.HOLD
|
||||
).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)
|
||||
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" } }
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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 {
|
||||
fun findStateWithLock(id: Long): ScheduleStateResponse {
|
||||
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
|
||||
|
||||
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
|
||||
@ -188,20 +77,20 @@ class ScheduleService(
|
||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||
}
|
||||
|
||||
return schedule.toSummaryResponse()
|
||||
return schedule.toStateResponse()
|
||||
.also {
|
||||
log.info { "[findDateTimeById] 일정 개요 조회 완료: id=$id" }
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findScheduleOverviewById(id: Long): ScheduleOverviewResponse {
|
||||
fun findWithThemeAndStore(id: Long): ScheduleWithThemeAndStoreResponse {
|
||||
val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run {
|
||||
log.warn { "[findScheduleOverview] 일정 개요 조회 실패: id=$id" }
|
||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||
}
|
||||
|
||||
return overview.toOverviewResponse()
|
||||
return overview.toResponseWithThemeAndStore()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@ -213,16 +102,13 @@ class ScheduleService(
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Common (공통 메서드)
|
||||
// ========================================
|
||||
private fun findOrThrow(id: Long): ScheduleEntity {
|
||||
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
|
||||
private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
|
||||
log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
|
||||
|
||||
return scheduleRepository.findByIdOrNull(id)
|
||||
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
|
||||
return scheduleRepository.findByIdForUpdate(id)
|
||||
?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }
|
||||
?: run {
|
||||
log.warn { "[updateSchedule] 일정 조회 실패. id=$id" }
|
||||
log.warn { "[findForUpdateOrThrow] 일정 조회 실패. id=$id" }
|
||||
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.ScheduleRepository
|
||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest
|
||||
import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest
|
||||
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Component
|
||||
@ -22,11 +22,20 @@ private val log: KLogger = KotlinLogging.logger {}
|
||||
class ScheduleValidator(
|
||||
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) {
|
||||
val status: ScheduleStatus = schedule.status
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -51,7 +60,7 @@ class ScheduleValidator(
|
||||
private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
|
||||
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
|
||||
log.info {
|
||||
"[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
|
||||
"[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
|
||||
}
|
||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
|
||||
}
|
||||
@ -63,7 +72,7 @@ class ScheduleValidator(
|
||||
|
||||
if (inputDateTime.isBefore(now)) {
|
||||
log.info {
|
||||
"[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
|
||||
"[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
|
||||
}
|
||||
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
||||
}
|
||||
@ -73,7 +82,7 @@ class ScheduleValidator(
|
||||
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
|
||||
.firstOrNull { it.containsTime(time) }
|
||||
?.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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.UserOnly
|
||||
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.responses.ApiResponse
|
||||
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)
|
||||
): 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
|
||||
@Query(
|
||||
"""
|
||||
@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
|
||||
s.holdExpiredAt = NULL
|
||||
WHERE
|
||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD
|
||||
AND s.holdExpiredAt <= :now
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ReservationEntity r
|
||||
WHERE r.scheduleId = s._id
|
||||
)
|
||||
s._id IN :scheduleIds
|
||||
"""
|
||||
)
|
||||
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.roomescape.common.types.AuditingInfo
|
||||
import com.sangdol.roomescape.schedule.business.AdminScheduleService
|
||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||
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 org.springframework.format.annotation.DateTimeFormat
|
||||
import org.springframework.http.ResponseEntity
|
||||
@ -13,7 +18,7 @@ import java.time.LocalDate
|
||||
@RestController
|
||||
@RequestMapping("/admin")
|
||||
class AdminScheduleController(
|
||||
private val scheduleService: ScheduleService,
|
||||
private val adminScheduleService: AdminScheduleService,
|
||||
) : AdminScheduleAPI {
|
||||
@GetMapping("/stores/{storeId}/schedules")
|
||||
override fun searchSchedules(
|
||||
@ -21,7 +26,7 @@ class AdminScheduleController(
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
|
||||
@RequestParam(required = false) themeId: Long?,
|
||||
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> {
|
||||
val response = scheduleService.searchSchedules(storeId, date, themeId)
|
||||
val response = adminScheduleService.searchSchedules(storeId, date, themeId)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
@ -30,7 +35,7 @@ class AdminScheduleController(
|
||||
override fun findScheduleAudit(
|
||||
@PathVariable("id") id: Long
|
||||
): ResponseEntity<CommonApiResponse<AuditingInfo>> {
|
||||
val response = scheduleService.findScheduleAudit(id)
|
||||
val response = adminScheduleService.findScheduleAudit(id)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
@ -40,7 +45,7 @@ class AdminScheduleController(
|
||||
@PathVariable("storeId") storeId: Long,
|
||||
@Valid @RequestBody request: ScheduleCreateRequest
|
||||
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
|
||||
val response = scheduleService.createSchedule(storeId, request)
|
||||
val response = adminScheduleService.createSchedule(storeId, request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
@ -50,7 +55,7 @@ class AdminScheduleController(
|
||||
@PathVariable("id") id: Long,
|
||||
@Valid @RequestBody request: ScheduleUpdateRequest
|
||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
scheduleService.updateSchedule(id, request)
|
||||
adminScheduleService.updateSchedule(id, request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(Unit))
|
||||
}
|
||||
@ -59,7 +64,7 @@ class AdminScheduleController(
|
||||
override fun deleteSchedule(
|
||||
@PathVariable("id") id: Long
|
||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
scheduleService.deleteSchedule(id)
|
||||
adminScheduleService.deleteSchedule(id)
|
||||
|
||||
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.docs.PublicScheduleAPI
|
||||
import com.sangdol.roomescape.schedule.docs.UserScheduleAPI
|
||||
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
|
||||
import org.springframework.format.annotation.DateTimeFormat
|
||||
import org.springframework.http.ResponseEntity
|
||||
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.common.types.AuditingInfo
|
||||
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.StoreException
|
||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
||||
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.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
@ -26,7 +34,7 @@ class StoreService(
|
||||
private val idGenerator: IDGenerator,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getDetail(id: Long): DetailStoreResponse {
|
||||
fun getDetail(id: Long): StoreDetailResponse {
|
||||
log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" }
|
||||
|
||||
val store: StoreEntity = findOrThrow(id)
|
||||
@ -85,7 +93,7 @@ class StoreService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse {
|
||||
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse {
|
||||
log.info { "[getAllActiveStores] 전체 매장 조회 시작" }
|
||||
|
||||
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.StoreException
|
||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
||||
import com.sangdol.roomescape.store.web.StoreRegisterRequest
|
||||
import com.sangdol.roomescape.store.web.StoreUpdateRequest
|
||||
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
|
||||
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
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.auth.web.support.AdminOnly
|
||||
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.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
@ -21,7 +26,7 @@ interface AdminStoreAPI {
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun findStoreDetail(
|
||||
@PathVariable id: Long
|
||||
): ResponseEntity<CommonApiResponse<DetailStoreResponse>>
|
||||
): ResponseEntity<CommonApiResponse<StoreDetailResponse>>
|
||||
|
||||
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
|
||||
@Operation(summary = "매장 등록")
|
||||
@ -53,7 +58,7 @@ interface PublicStoreAPI {
|
||||
fun getStores(
|
||||
@RequestParam(value = "sido", required = false) sidoCode: String?,
|
||||
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
|
||||
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>>
|
||||
): ResponseEntity<CommonApiResponse<StoreNameListResponse>>
|
||||
|
||||
@Public
|
||||
@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.roomescape.store.business.StoreService
|
||||
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 org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
@ -16,8 +20,8 @@ class AdminStoreController(
|
||||
@GetMapping("/{id}/detail")
|
||||
override fun findStoreDetail(
|
||||
@PathVariable id: Long
|
||||
): ResponseEntity<CommonApiResponse<DetailStoreResponse>> {
|
||||
val response: DetailStoreResponse = storeService.getDetail(id)
|
||||
): ResponseEntity<CommonApiResponse<StoreDetailResponse>> {
|
||||
val response: StoreDetailResponse = storeService.getDetail(id)
|
||||
|
||||
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.roomescape.store.business.StoreService
|
||||
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.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
@ -18,7 +20,7 @@ class StoreController(
|
||||
override fun getStores(
|
||||
@RequestParam(value = "sido", required = false) sidoCode: String?,
|
||||
@RequestParam(value = "sigungu", required = false) sigunguCode: String?
|
||||
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>> {
|
||||
): ResponseEntity<CommonApiResponse<StoreNameListResponse>> {
|
||||
val response = storeService.getAllActiveStores(sidoCode, sigunguCode)
|
||||
|
||||
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
|
||||
|
||||
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.theme.dto.ThemeInfoListResponse
|
||||
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
||||
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.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.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
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 {}
|
||||
|
||||
/**
|
||||
* Structure:
|
||||
* - Public: 모두가 접근 가능한 메서드
|
||||
* - Store Admin: 매장 관리자가 사용하는 메서드
|
||||
* - HQ Admin: 본사 관리자가 사용하는 메서드
|
||||
* - Common: 공통 메서드
|
||||
*/
|
||||
@Service
|
||||
class ThemeService(
|
||||
private val themeRepository: ThemeRepository,
|
||||
private val themeValidator: ThemeValidator,
|
||||
private val idGenerator: IDGenerator,
|
||||
private val adminService: AdminService
|
||||
private val themeRepository: ThemeRepository
|
||||
) {
|
||||
// ========================================
|
||||
// Public (인증 불필요)
|
||||
// ========================================
|
||||
@Transactional(readOnly = true)
|
||||
fun findInfoById(id: Long): ThemeInfoResponse {
|
||||
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" } }
|
||||
}
|
||||
|
||||
@ -54,115 +48,11 @@ class ThemeService(
|
||||
.also {
|
||||
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.ThemeException
|
||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||
import com.sangdol.roomescape.theme.web.ThemeCreateRequest
|
||||
import com.sangdol.roomescape.theme.web.ThemeUpdateRequest
|
||||
import com.sangdol.roomescape.theme.dto.ThemeCreateRequest
|
||||
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
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.auth.web.support.AdminOnly
|
||||
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.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
@ -20,12 +27,12 @@ interface AdminThemeAPI {
|
||||
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY)
|
||||
@Operation(summary = "모든 테마 조회")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>>
|
||||
fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
|
||||
|
||||
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL)
|
||||
@Operation(summary = "테마 상세 조회")
|
||||
@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)
|
||||
@Operation(summary = "테마 추가")
|
||||
@ -48,7 +55,7 @@ interface AdminThemeAPI {
|
||||
@AdminOnly(privilege = Privilege.READ_SUMMARY)
|
||||
@Operation(summary = "현재 활성화 상태인 테마 ID + 이름 목록 조회")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun getActiveThemes(): ResponseEntity<CommonApiResponse<SimpleActiveThemeListResponse>>
|
||||
fun getActiveThemes(): ResponseEntity<CommonApiResponse<ThemeNameListResponse>>
|
||||
}
|
||||
|
||||
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)
|
||||
GROUP BY
|
||||
s.theme_id
|
||||
ORDER BY
|
||||
reservation_count desc
|
||||
LIMIT :count
|
||||
) ranked_themes ON t.id = ranked_themes.theme_id
|
||||
ORDER BY ranked_themes.reservation_count DESC, t.id ASC
|
||||
""",
|
||||
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.dto.ThemeInfoListResponse
|
||||
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
|
||||
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
fun ThemeInfo.toInfoResponse() = ThemeInfoResponse(
|
||||
fun ThemeInfo.toResponse() = ThemeInfoResponse(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
thumbnailUrl = this.thumbnailUrl,
|
||||
@ -45,10 +33,8 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
|
||||
expectedMinutesTo = this.expectedMinutesTo
|
||||
)
|
||||
|
||||
data class ThemeInfoListResponse(
|
||||
val themes: List<ThemeInfoResponse>
|
||||
fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
|
||||
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
|
||||
|
||||
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.dto.*
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
@ -10,19 +11,19 @@ import java.net.URI
|
||||
|
||||
@RestController
|
||||
class AdminThemeController(
|
||||
private val themeService: ThemeService,
|
||||
private val adminThemeService: AdminThemeService,
|
||||
) : AdminThemeAPI {
|
||||
|
||||
@GetMapping("/admin/themes")
|
||||
override fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>> {
|
||||
val response = themeService.findAdminThemes()
|
||||
override fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>> {
|
||||
val response = adminThemeService.findThemeSummaries()
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/admin/themes/{id}")
|
||||
override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>> {
|
||||
val response = themeService.findAdminThemeDetail(id)
|
||||
override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeDetailResponse>> {
|
||||
val response = adminThemeService.findThemeDetail(id)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
@ -31,7 +32,7 @@ class AdminThemeController(
|
||||
override fun createTheme(
|
||||
@Valid @RequestBody themeCreateRequest: ThemeCreateRequest
|
||||
): ResponseEntity<CommonApiResponse<ThemeCreateResponse>> {
|
||||
val response = themeService.createTheme(themeCreateRequest)
|
||||
val response = adminThemeService.createTheme(themeCreateRequest)
|
||||
|
||||
return ResponseEntity.created(URI.create("/admin/themes/${response.id}"))
|
||||
.body(CommonApiResponse(response))
|
||||
@ -39,7 +40,7 @@ class AdminThemeController(
|
||||
|
||||
@DeleteMapping("/admin/themes/{id}")
|
||||
override fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
themeService.deleteTheme(id)
|
||||
adminThemeService.deleteTheme(id)
|
||||
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
@ -49,14 +50,14 @@ class AdminThemeController(
|
||||
@PathVariable id: Long,
|
||||
@Valid @RequestBody request: ThemeUpdateRequest
|
||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||
themeService.updateTheme(id, request)
|
||||
adminThemeService.updateTheme(id, request)
|
||||
|
||||
return ResponseEntity.ok().build()
|
||||
}
|
||||
|
||||
@GetMapping("/admin/themes/active")
|
||||
override fun getActiveThemes(): ResponseEntity<CommonApiResponse<SimpleActiveThemeListResponse>> {
|
||||
val response = themeService.findActiveThemes()
|
||||
override fun getActiveThemes(): ResponseEntity<CommonApiResponse<ThemeNameListResponse>> {
|
||||
val response = adminThemeService.findActiveThemes()
|
||||
|
||||
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