[#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 (#57)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #56

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
<img width="1163" alt="스크린샷 2025-10-09 18.26.43.png" src="attachments/b1651431-c1c4-4198-84c8-2019bde70dd6">
- '결제 요청 API가 호출된 이상 사용자는 결제를 시도한 것으로 간주한다' + 'PG사 정상 응답만 오면 사용자는 결제를 성공한 것이다' 의 관점으로 구현
- 결제 요청 API가 호출되면, 이미 예약이 완료, 취소, 만료된 것이 아니라면 검증 후 해당 예약을 배치가 처리하지 못하게 PAYMENT_IN_PROGRESS로 변경
- PG사 결제가 성공하면, 이후의 결제 & 예약 정보 저장의 성공 여부와 무관하게 일단 API는 성공 응답 전송
- 매 결제 시도는 성공 / 실패 여부와 무관하게 이력 저장 => 결제 시도 횟수가 N번 이상이면 프론트엔드에서 특정 처리(=> 현장결제 페이지 안내 예정. 현재 바로는 구현 계획 X)
- 기존 배치와의 경합 + 데드락 방지를 위해 배치 작업을 조회 -> 수정 두 단계로 변경했고, 조회 단계에서는 SKIP LOCKED 사용.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 새로 통합한 Order 관련 API 테스트 및 기존 배치와의 경합 상황 테스트

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #57
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
이상진 2025-10-09 09:33:29 +00:00 committed by 이상진
parent 8215492eea
commit 047e4a395b
132 changed files with 3123 additions and 1546 deletions

View File

@ -1,6 +1,5 @@
package com.sangdol.common.web.exception package com.sangdol.common.web.exception
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.exception.CommonErrorCode import com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
@ -31,7 +30,7 @@ class GlobalExceptionHandler(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException(servletRequest, httpStatus, errorResponse, e) log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity return ResponseEntity
.status(httpStatus.value()) .status(httpStatus.value())
@ -57,7 +56,7 @@ class GlobalExceptionHandler(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException(servletRequest, httpStatus, errorResponse, e) log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity return ResponseEntity
.status(httpStatus.value()) .status(httpStatus.value())
@ -75,30 +74,26 @@ class GlobalExceptionHandler(
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
logException(servletRequest, httpStatus, errorResponse, e) log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity return ResponseEntity
.status(httpStatus.value()) .status(httpStatus.value())
.body(errorResponse) .body(errorResponse)
} }
private fun logException( private fun convertExceptionLogMessage(
servletRequest: HttpServletRequest, servletRequest: HttpServletRequest,
httpStatus: HttpStatus, httpStatus: HttpStatus,
errorResponse: CommonErrorResponse, errorResponse: CommonErrorResponse,
exception: Exception exception: Exception
) { ): String {
val type = if (httpStatus.isClientError()) LogType.APPLICATION_FAILURE else LogType.UNHANDLED_EXCEPTION
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
val logMessage = messageConverter.convertToResponseMessage( return messageConverter.convertToErrorResponseMessage(
type = type,
servletRequest = servletRequest, servletRequest = servletRequest,
httpStatusCode = httpStatus.value(), httpStatus = httpStatus,
responseBody = errorResponse, responseBody = errorResponse,
exception = actualException exception = actualException
) )
log.warn { logMessage }
} }
} }

View File

@ -2,6 +2,7 @@ package com.sangdol.common.web.support.log
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.log.constant.LogType import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverter( class WebLogMessageConverter(
@ -49,4 +50,19 @@ class WebLogMessageConverter(
return objectMapper.writeValueAsString(payload) return objectMapper.writeValueAsString(payload)
} }
fun convertToErrorResponseMessage(
servletRequest: HttpServletRequest,
httpStatus: HttpStatus,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val type = if (httpStatus.isClientError()) {
LogType.APPLICATION_FAILURE
} else {
LogType.UNHANDLED_EXCEPTION
}
return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception)
}
} }

View File

@ -168,5 +168,27 @@ class WebLogMessageConverterTest : FunSpec({
this["exception"] shouldBe null this["exception"] shouldBe null
} }
} }
test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.BAD_REQUEST,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.APPLICATION_FAILURE.name
}
}
test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name
}
}
} }
}) })

View File

@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
paymentKey: string; paymentKey: string;
orderId: string; orderId: string;
amount: number; amount: number;
paymentType: PaymentType;
} }
export interface PaymentCancelRequest { export interface PaymentCancelRequest {

View File

@ -1,5 +1,3 @@
import type { Difficulty } from '@_api/theme/themeTypes';
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED'; export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
export const ScheduleStatus = { export const ScheduleStatus = {
@ -40,14 +38,33 @@ export interface AdminScheduleSummaryListResponse {
} }
// Public // Public
export interface ScheduleResponse {
id: string;
date: string;
startFrom: string;
endAt: string;
status: ScheduleStatus;
}
export interface ScheduleThemeInfo {
id: string;
name: string;
}
export interface ScheduleStoreInfo {
id: string;
name: string;
}
export interface ScheduleWithStoreAndThemeResponse {
schedule: ScheduleResponse,
theme: ScheduleThemeInfo,
store: ScheduleStoreInfo,
}
export interface ScheduleWithThemeResponse { export interface ScheduleWithThemeResponse {
id: string, schedule: ScheduleResponse,
startFrom: string, theme: ScheduleThemeInfo
endAt: string,
themeId: string,
themeName: string,
themeDifficulty: Difficulty,
status: ScheduleStatus
} }
export interface ScheduleWithThemeListResponse { export interface ScheduleWithThemeListResponse {

View File

@ -1,17 +1,17 @@
import {isLoginRequiredError} from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI'; import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes'; import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI'; import { type ReservationData } from '@_api/reservation/reservationTypes';
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes'; import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
import {getStores} from '@_api/store/storeAPI'; import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
import {type SimpleStoreResponse} from '@_api/store/storeTypes'; import { getStores } from '@_api/store/storeAPI';
import {fetchThemeById} from '@_api/theme/themeAPI'; import { type SimpleStoreResponse } from '@_api/store/storeTypes';
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes'; import { fetchThemeById } from '@_api/theme/themeAPI';
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import {type ReservationData} from '@_api/reservation/reservationTypes'; import { formatDate } from 'src/util/DateTimeFormatter';
import {formatDate} from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => { const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
fetchSchedules(selectedStore.id, dateStr) fetchSchedules(selectedStore.id, dateStr)
.then(res => { .then(res => {
const grouped = res.schedules.reduce((acc, schedule) => { const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.themeName; const key = schedule.theme.name;
if (!acc[key]) acc[key] = []; if (!acc[key]) acc[key] = [];
acc[key].push(schedule); acc[key].push(schedule);
return acc; return acc;
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
const handleConfirmReservation = () => { const handleConfirmReservation = () => {
if (!selectedSchedule) return; if (!selectedSchedule) return;
holdSchedule(selectedSchedule.id) holdSchedule(selectedSchedule.schedule.id)
.then(() => { .then(() => {
fetchThemeById(selectedSchedule.themeId).then(res => { fetchThemeById(selectedSchedule.theme.id).then(res => {
const reservationData: ReservationData = { const reservationData: ReservationData = {
scheduleId: selectedSchedule.id, scheduleId: selectedSchedule.schedule.id,
store: { store: {
id: selectedStore!.id, id: selectedStore!.id,
name: selectedStore!.name, name: selectedStore!.name,
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
maxParticipants: res.maxParticipants, maxParticipants: res.maxParticipants,
}, },
date: selectedDate.toLocaleDateString('en-CA'), date: selectedDate.toLocaleDateString('en-CA'),
startFrom: selectedSchedule.startFrom, startFrom: selectedSchedule.schedule.startFrom,
endAt: selectedSchedule.endAt, endAt: selectedSchedule.schedule.endAt,
}; };
navigate('/reservation/form', {state: reservationData}); navigate('/reservation/form', {state: reservationData});
}).catch(handleError); }).catch(handleError);
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
<h3>3. </h3> <h3>3. </h3>
<div className="schedule-list"> <div className="schedule-list">
{Object.keys(schedulesByTheme).length > 0 ? ( {Object.keys(schedulesByTheme).length > 0 ? (
Object.entries(schedulesByTheme).map(([themeName, schedules]) => ( Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
<div key={themeName} className="theme-schedule-group"> <div key={themeName} className="theme-schedule-group">
<div className="theme-header"> <div className="theme-header">
<h4>{themeName}</h4> <h4>{themeName}</h4>
<button onClick={() => openThemeModal(schedules[0].themeId)} <button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
className="theme-detail-button"> className="theme-detail-button">
</button> </button>
</div> </div>
<div className="time-slots"> <div className="time-slots">
{schedules.map(schedule => ( {scheduleAndTheme.map(schedule => (
<div <div
key={schedule.id} key={schedule.schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`} className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)} onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
> >
{`${schedule.startFrom} ~ ${schedule.endAt}`} {`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.status)}</span> <span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
</div> </div>
))} ))}
</div> </div>
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
<div className="modal-section modal-info-grid"> <div className="modal-section modal-info-grid">
<p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p> <p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong><span>{selectedStore?.name}</span></p> <p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong><span>{selectedSchedule.themeName}</span></p> <p><strong>:</strong><span>{selectedSchedule.theme.name}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p> <p><strong>:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button> <button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button>

View File

@ -1,8 +1,9 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { confirm } from '@_api/order/orderAPI';
import { confirmPayment } from '@_api/payment/paymentAPI'; import type { BookingErrorResponse } from '@_api/order/orderTypes';
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes'; import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import { confirmReservation } from '@_api/reservation/reservationAPI'; import { confirmReservation } from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import type { AxiosError } from 'axios';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter'; import { formatDate } from 'src/util/DateTimeFormatter';
@ -21,17 +22,6 @@ const ReservationStep2Page: React.FC = () => {
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {}; const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => { useEffect(() => {
if (!reservationId) { if (!reservationId) {
alert('잘못된 접근입니다.'); alert('잘못된 접근입니다.');
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
paymentKey: data.paymentKey, paymentKey: data.paymentKey,
orderId: data.orderId, orderId: data.orderId,
amount: totalPrice, amount: totalPrice,
paymentType: data.paymentType || PaymentType.NORMAL,
}; };
confirm(reservationId, paymentData)
confirmPayment(reservationId, paymentData)
.then(() => {
return confirmReservation(reservationId);
})
.then(() => { .then(() => {
alert('결제가 완료되었어요!'); alert('결제가 완료되었어요!');
navigate('/reservation/success', { navigate('/reservation/success', {
@ -97,10 +82,50 @@ const ReservationStep2Page: React.FC = () => {
} }
}); });
}) })
.catch(handleError); .catch(err => {
const error = err as AxiosError<BookingErrorResponse>;
const errorCode = error.response?.data?.code;
const errorMessage = error.response?.data?.message;
if (errorCode === 'B000') {
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
navigate('/reservation');
return;
}
const trial = error.response?.data?.trial || 0;
if (trial < 2) {
alert(errorMessage);
return;
}
alert(errorMessage);
setTimeout(() => {
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
if (agreeToOnsitePayment) {
confirmReservation(reservationId)
.then(() => {
navigate('/reservation/success', {
state: {
storeName,
themeName,
date,
time,
participantCount,
totalPrice,
},
});
});
} else {
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
navigate('/');
}
}, 100);
});
}).catch((error: any) => { }).catch((error: any) => {
console.error("Payment request error:", error); console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했습니다."); alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
}); });
}; };

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.admin.business package com.sangdol.roomescape.admin.business
import com.sangdol.roomescape.admin.business.dto.AdminLoginCredentials import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
import com.sangdol.roomescape.admin.business.dto.toCredentials import com.sangdol.roomescape.admin.mapper.toCredentials
import com.sangdol.roomescape.admin.exception.AdminErrorCode import com.sangdol.roomescape.admin.exception.AdminErrorCode
import com.sangdol.roomescape.admin.exception.AdminException import com.sangdol.roomescape.admin.exception.AdminException
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository

View File

@ -1,10 +1,9 @@
package com.sangdol.roomescape.admin.business.dto package com.sangdol.roomescape.admin.dto
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.web.LoginCredentials import com.sangdol.roomescape.auth.dto.LoginCredentials
import com.sangdol.roomescape.auth.web.LoginSuccessResponse import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
data class AdminLoginCredentials( data class AdminLoginCredentials(
override val id: Long, override val id: Long,
@ -22,15 +21,6 @@ data class AdminLoginCredentials(
) )
} }
fun AdminEntity.toCredentials() = AdminLoginCredentials(
id = this.id,
password = this.password,
name = this.name,
type = this.type,
storeId = this.storeId,
permissionLevel = this.permissionLevel
)
data class AdminLoginSuccessResponse( data class AdminLoginSuccessResponse(
override val accessToken: String, override val accessToken: String,
override val name: String, override val name: String,

View File

@ -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
)

View File

@ -1,10 +1,14 @@
package com.sangdol.roomescape.auth.business package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.dto.LoginCredentials
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.*
import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.business.UserService
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging

View File

@ -3,8 +3,8 @@ package com.sangdol.roomescape.auth.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.web.LoginContext import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.web.PrincipalType import com.sangdol.roomescape.auth.business.domain.PrincipalType
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service

View File

@ -0,0 +1,5 @@
package com.sangdol.roomescape.auth.business.domain
enum class PrincipalType {
USER, ADMIN
}

View File

@ -1,8 +1,8 @@
package com.sangdol.roomescape.auth.docs package com.sangdol.roomescape.auth.docs
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.LoginRequest import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.web.LoginSuccessResponse import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext

View File

@ -1,21 +1,12 @@
package com.sangdol.roomescape.auth.web package com.sangdol.roomescape.auth.dto
import jakarta.servlet.http.HttpServletRequest import com.sangdol.roomescape.auth.business.domain.PrincipalType
enum class PrincipalType {
USER, ADMIN
}
data class LoginContext( data class LoginContext(
val ipAddress: String, val ipAddress: String,
val userAgent: String, val userAgent: String,
) )
fun HttpServletRequest.toLoginContext() = LoginContext(
ipAddress = this.remoteAddr,
userAgent = this.getHeader("User-Agent")
)
data class LoginRequest( data class LoginRequest(
val account: String, val account: String,
val password: String, val password: String,

View File

@ -1,7 +1,7 @@
package com.sangdol.roomescape.auth.infrastructure.persistence package com.sangdol.roomescape.auth.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.auth.web.PrincipalType import com.sangdol.roomescape.auth.business.domain.PrincipalType
import jakarta.persistence.* import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener

View File

@ -3,6 +3,9 @@ package com.sangdol.roomescape.auth.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.business.AuthService import com.sangdol.roomescape.auth.business.AuthService
import com.sangdol.roomescape.auth.docs.AuthAPI import com.sangdol.roomescape.auth.docs.AuthAPI
import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@ -36,3 +39,8 @@ class AuthController(
return ResponseEntity.ok().build() return ResponseEntity.ok().build()
} }
} }
fun HttpServletRequest.toLoginContext() = LoginContext(
ipAddress = this.remoteAddr,
userAgent = this.getHeader("User-Agent")
)

View File

@ -0,0 +1,60 @@
package com.sangdol.roomescape.order.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskEntity
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.reservation.business.ReservationService
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
private val log: KLogger = KotlinLogging.logger {}
@Service
class OrderPostProcessorService(
private val idGenerator: IDGenerator,
private val reservationService: ReservationService,
private val paymentService: PaymentService,
private val postOrderTaskRepository: PostOrderTaskRepository,
private val transactionExecutionUtil: TransactionExecutionUtil
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun processAfterPaymentConfirmation(
reservationId: Long,
paymentResponse: PaymentGatewayResponse
) {
val paymentKey = paymentResponse.paymentKey
try {
log.info { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
val paymentCreateResponse = paymentService.savePayment(reservationId, paymentResponse)
reservationService.confirmReservation(reservationId)
log.info {
"[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 완료: reservationId=${reservationId}, paymentKey=${paymentKey}, paymentId=${paymentCreateResponse.paymentId}, paymentDetailId=${paymentCreateResponse.detailId}"
}
} catch (e: Exception) {
log.warn(e) { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 실패. 작업 저장 시작: reservationId=${reservationId}, paymentKey=$paymentKey}" }
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
PostOrderTaskEntity(
id = idGenerator.create(),
reservationId = reservationId,
paymentKey = paymentKey,
trial = 1,
nextRetryAt = Instant.now().plusSeconds(30),
).also {
postOrderTaskRepository.save(it)
}
}
log.info { "[processAfterPaymentConfirmation] 작업 저장 완료" }
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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>>
}

View File

@ -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", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
;
}

View File

@ -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
)

View File

@ -0,0 +1,45 @@
package com.sangdol.roomescape.order.exception
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
private val log: KLogger = KotlinLogging.logger {}
@RestControllerAdvice
class OrderExceptionHandler(
private val messageConverter: WebLogMessageConverter
) {
@ExceptionHandler(OrderException::class)
fun handleOrderException(
servletRequest: HttpServletRequest,
e: OrderException
): ResponseEntity<OrderErrorResponse> {
val errorCode: ErrorCode = e.errorCode
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = OrderErrorResponse(
code = errorCode.errorCode,
message = if (httpStatus.isClientError()) e.message else errorCode.message,
trial = e.trial
)
log.info {
messageConverter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = httpStatus,
responseBody = errorResponse,
exception = if (errorCode.message == e.message) null else e
)
}
return ResponseEntity
.status(httpStatus.value())
.body(errorResponse)
}
}

View File

@ -0,0 +1,38 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
@Entity
@EntityListeners(AuditingEntityListener::class)
@Table(name = "payment_attempts")
class PaymentAttemptEntity(
id: Long,
val reservationId: Long,
@Enumerated(value = EnumType.STRING)
val result: AttemptResult,
@Column(columnDefinition = "VARCHAR(50)")
val errorCode: String? = null,
@Column(columnDefinition = "TEXT")
val message: String? = null,
) : PersistableBaseEntity(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: Instant
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
}
enum class AttemptResult {
SUCCESS, FAILED
}

View File

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

View File

@ -0,0 +1,16 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import jakarta.persistence.Entity
import jakarta.persistence.Table
import java.time.Instant
@Entity
@Table(name = "post_order_tasks")
class PostOrderTaskEntity(
id: Long,
val reservationId: Long,
val paymentKey: String,
val trial: Int,
val nextRetryAt: Instant
) : PersistableBaseEntity(id)

View File

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

View File

@ -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())
}
}

View File

@ -1,13 +1,14 @@
package com.sangdol.roomescape.payment.business package com.sangdol.roomescape.payment.business
import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
import com.sangdol.roomescape.payment.dto.*
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientCancelResponse
import com.sangdol.roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.web.* import com.sangdol.roomescape.payment.mapper.toResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -24,33 +25,54 @@ class PaymentService(
private val paymentWriter: PaymentWriter, private val paymentWriter: PaymentWriter,
private val transactionExecutionUtil: TransactionExecutionUtil, private val transactionExecutionUtil: TransactionExecutionUtil,
) { ) {
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse { fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse {
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm( log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
paymentKey = request.paymentKey, try {
orderId = request.orderId, return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
amount = request.amount, log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" }
) }
} catch (e: Exception) {
when(e) {
is ExternalPaymentException -> {
val errorCode = if (e.httpStatusCode in 400..<500) {
PaymentErrorCode.PAYMENT_CLIENT_ERROR
} else {
PaymentErrorCode.PAYMENT_PROVIDER_ERROR
}
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) { val message = if (UserFacingPaymentErrorCode.contains(e.errorCode)) {
val payment: PaymentEntity = paymentWriter.createPayment( "${errorCode.message}(${e.message})"
reservationId = reservationId, } else {
orderId = request.orderId, errorCode.message
paymentType = request.paymentType, }
paymentClientConfirmResponse = clientConfirmResponse
)
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id) throw PaymentException(errorCode, message)
} ?: run { }
log.warn { "[confirm] 결제 확정 중 예상치 못한 null 반환" } else -> {
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) log.warn(e) { "[requestConfirm] 예상치 못한 결제 실패: paymentKey=${request.paymentKey}" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
}
} }
} }
fun savePayment(
reservationId: Long,
paymentGatewayResponse: PaymentGatewayResponse
): PaymentCreateResponse {
val payment: PaymentEntity = paymentWriter.createPayment(
reservationId = reservationId,
paymentGatewayResponse = paymentGatewayResponse
)
val detail: PaymentDetailEntity = paymentWriter.createDetail(paymentGatewayResponse, payment.id)
return PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
}
fun cancel(userId: Long, request: PaymentCancelRequest) { fun cancel(userId: Long, request: PaymentCancelRequest) {
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId) val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel( val clientCancelResponse: PaymentGatewayCancelResponse = paymentClient.cancel(
paymentKey = payment.paymentKey, paymentKey = payment.paymentKey,
amount = payment.totalAmount, amount = payment.totalAmount,
cancelReason = request.cancelReason cancelReason = request.cancelReason
@ -69,16 +91,16 @@ class PaymentService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? { fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" } log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId) val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) } val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) } val cancelDetail: CanceledPaymentEntity? = payment?.let { findCancelByPaymentIdOrNull(it.id) }
return payment?.toDetailResponse( return payment?.toResponse(
detail = paymentDetail?.toPaymentDetailResponse(), detail = paymentDetail?.toResponse(),
cancel = cancelDetail?.toCancelDetailResponse() cancel = cancelDetail?.toResponse()
) )
} }

View File

@ -3,9 +3,13 @@ package com.sangdol.roomescape.payment.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.* import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.mapper.toCardDetailEntity
import com.sangdol.roomescape.payment.mapper.toEasypayPrepaidDetailEntity
import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.payment.mapper.toTransferDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@ -24,35 +28,31 @@ class PaymentWriter(
fun createPayment( fun createPayment(
reservationId: Long, reservationId: Long,
orderId: String, paymentGatewayResponse: PaymentGatewayResponse
paymentType: PaymentType,
paymentClientConfirmResponse: PaymentClientConfirmResponse
): PaymentEntity { ): PaymentEntity {
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" } log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentGatewayResponse.paymentKey}" }
return paymentClientConfirmResponse.toEntity( return paymentGatewayResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also {
id = idGenerator.create(), reservationId, orderId, paymentType
).also {
paymentRepository.save(it) paymentRepository.save(it)
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" } log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
} }
} }
fun createDetail( fun createDetail(
paymentResponse: PaymentClientConfirmResponse, paymentGatewayResponse: PaymentGatewayResponse,
paymentId: Long, paymentId: Long,
): PaymentDetailEntity { ): PaymentDetailEntity {
val method: PaymentMethod = paymentResponse.method val method: PaymentMethod = paymentGatewayResponse.method
val id = idGenerator.create() val id = idGenerator.create()
if (method == PaymentMethod.TRANSFER) { if (method == PaymentMethod.TRANSFER) {
return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId)) return paymentDetailRepository.save(paymentGatewayResponse.toTransferDetailEntity(id, paymentId))
} }
if (method == PaymentMethod.EASY_PAY && paymentResponse.card == null) { if (method == PaymentMethod.EASY_PAY && paymentGatewayResponse.card == null) {
return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId)) return paymentDetailRepository.save(paymentGatewayResponse.toEasypayPrepaidDetailEntity(id, paymentId))
} }
if (paymentResponse.card != null) { if (paymentGatewayResponse.card != null) {
return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId)) return paymentDetailRepository.save(paymentGatewayResponse.toCardDetailEntity(id, paymentId))
} }
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
} }
@ -61,7 +61,7 @@ class PaymentWriter(
userId: Long, userId: Long,
payment: PaymentEntity, payment: PaymentEntity,
requestedAt: Instant, requestedAt: Instant,
cancelResponse: PaymentClientCancelResponse cancelResponse: PaymentGatewayCancelResponse
): CanceledPaymentEntity { ): CanceledPaymentEntity {
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" } log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }

View File

@ -1,4 +1,4 @@
package com.sangdol.roomescape.payment.infrastructure.common package com.sangdol.roomescape.payment.business.domain
import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonCreator
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode

View File

@ -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 }
}
}
}

View File

@ -4,16 +4,15 @@ import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.web.PaymentCancelRequest import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import com.sangdol.roomescape.payment.web.PaymentCreateResponse import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
interface PaymentAPI { interface PaymentAPI {
@ -21,9 +20,8 @@ interface PaymentAPI {
@Operation(summary = "결제 승인") @Operation(summary = "결제 승인")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun confirmPayment( fun confirmPayment(
@RequestParam(required = true) reservationId: Long,
@Valid @RequestBody request: PaymentConfirmRequest @Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>> ): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>>
@Operation(summary = "결제 취소") @Operation(summary = "결제 취소")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))

View File

@ -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
)

View File

@ -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,
)

View File

@ -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()
)

View File

@ -6,3 +6,9 @@ class PaymentException(
override val errorCode: PaymentErrorCode, override val errorCode: PaymentErrorCode,
override val message: String = errorCode.message override val message: String = errorCode.message
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)
class ExternalPaymentException(
val httpStatusCode: Int,
val errorCode: String,
override val message: String
) : RuntimeException(message)

View File

@ -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)
}
}

View File

@ -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()
)
}
}

View File

@ -1,6 +1,9 @@
package com.sangdol.roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
@ -28,7 +31,7 @@ class TosspayClient(
paymentKey: String, paymentKey: String,
orderId: String, orderId: String,
amount: Int, amount: Int,
): PaymentClientConfirmResponse { ): PaymentGatewayResponse {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" } log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
@ -42,7 +45,7 @@ class TosspayClient(
paymentKey: String, paymentKey: String,
amount: Int, amount: Int,
cancelReason: String cancelReason: String
): PaymentClientCancelResponse { ): PaymentGatewayCancelResponse {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" } log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
@ -62,7 +65,7 @@ private class ConfirmClient(
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper) private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
fun request(paymentKey: String, orderId: String, amount: Int): PaymentClientConfirmResponse { fun request(paymentKey: String, orderId: String, amount: Int): PaymentGatewayResponse {
val response = client.post() val response = client.post()
.uri(CONFIRM_URI) .uri(CONFIRM_URI)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@ -83,7 +86,7 @@ private class ConfirmClient(
log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" } log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" }
return objectMapper.readValue(response, PaymentClientConfirmResponse::class.java) return objectMapper.readValue(response, PaymentGatewayResponse::class.java)
} }
} }
@ -101,7 +104,7 @@ private class CancelClient(
paymentKey: String, paymentKey: String,
amount: Int, amount: Int,
cancelReason: String cancelReason: String
): PaymentClientCancelResponse { ): PaymentGatewayCancelResponse {
val response = client.post() val response = client.post()
.uri(CANCEL_URI, paymentKey) .uri(CANCEL_URI, paymentKey)
.body( .body(
@ -119,7 +122,7 @@ private class CancelClient(
} }
log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" } log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" }
return objectMapper.readValue(response, PaymentClientCancelResponse::class.java) return objectMapper.readValue(response, PaymentGatewayCancelResponse::class.java)
} }
} }
@ -138,9 +141,20 @@ private class TosspayErrorHandler(
response: ClientHttpResponse response: ClientHttpResponse
): Nothing { ): Nothing {
val requestType: String = paymentRequestType(url) val requestType: String = paymentRequestType(url)
log.warn { "[TosspayClient] $requestType 요청 실패: response: ${parseResponse(response)}" } val errorResponse: TosspayErrorResponse = parseResponse(response)
val status = response.statusCode
throw PaymentException(paymentErrorCode(response.statusCode)) if (status.is5xxServerError) {
log.warn { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" }
} else {
log.info { "[TosspayClient] $requestType 요청 실패: response: $errorResponse" }
}
throw ExternalPaymentException(
httpStatusCode = status.value(),
errorCode = errorResponse.code,
message = errorResponse.message
)
} }
private fun paymentRequestType(url: URI): String { private fun paymentRequestType(url: URI): String {

View File

@ -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()
)
}
}

View File

@ -1,7 +1,11 @@
package com.sangdol.roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.payment.business.domain.BankCode
import com.sangdol.roomescape.payment.business.domain.CardIssuerCode
import com.sangdol.roomescape.payment.business.domain.CardOwnerType
import com.sangdol.roomescape.payment.business.domain.CardType
import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode
import jakarta.persistence.* import jakarta.persistence.*
@Entity @Entity

View File

@ -1,9 +1,9 @@
package com.sangdol.roomescape.payment.infrastructure.persistence package com.sangdol.roomescape.payment.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus import com.sangdol.roomescape.payment.business.domain.PaymentStatus
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType import com.sangdol.roomescape.payment.business.domain.PaymentType
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.EnumType import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated import jakarta.persistence.Enumerated

View File

@ -1,58 +1,29 @@
package com.sangdol.roomescape.payment.infrastructure.client package com.sangdol.roomescape.payment.mapper
import com.sangdol.roomescape.payment.dto.CancelDetail
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity import java.time.Instant
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
import java.time.OffsetDateTime
data class PaymentClientConfirmResponse( fun PaymentGatewayResponse.toEntity(
val paymentKey: String,
val status: PaymentStatus,
val totalAmount: Int,
val vat: Int,
val suppliedAmount: Int,
val method: PaymentMethod,
val card: CardDetail?,
val easyPay: EasyPayDetail?,
val transfer: TransferDetail?,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
)
fun PaymentClientConfirmResponse.toEntity(
id: Long, id: Long,
reservationId: Long, reservationId: Long,
orderId: String,
paymentType: PaymentType
) = PaymentEntity( ) = PaymentEntity(
id = id, id = id,
reservationId = reservationId, reservationId = reservationId,
paymentKey = this.paymentKey, paymentKey = this.paymentKey,
orderId = orderId, orderId = this.orderId,
totalAmount = this.totalAmount, totalAmount = this.totalAmount,
requestedAt = this.requestedAt.toInstant(), requestedAt = this.requestedAt.toInstant(),
approvedAt = this.approvedAt.toInstant(), approvedAt = this.approvedAt.toInstant(),
type = paymentType, type = this.type,
method = this.method, method = this.method,
status = this.status, status = this.status,
) )
data class CardDetail( fun PaymentGatewayResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
val issuerCode: CardIssuerCode,
val number: String,
val amount: Int,
val cardType: CardType,
val ownerType: CardOwnerType,
val isInterestFree: Boolean,
val approveNo: String,
val installmentPlanMonths: Int
)
fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return PaymentCardDetailEntity( return PaymentCardDetailEntity(
@ -73,13 +44,7 @@ fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long):
) )
} }
data class EasyPayDetail( fun PaymentGatewayResponse.toEasypayPrepaidDetailEntity(
val provider: EasyPayCompanyCode,
val amount: Int,
val discountAmount: Int,
)
fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
id: Long, id: Long,
paymentId: Long paymentId: Long
): PaymentEasypayPrepaidDetailEntity { ): PaymentEasypayPrepaidDetailEntity {
@ -96,12 +61,7 @@ fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
) )
} }
data class TransferDetail( fun PaymentGatewayResponse.toTransferDetailEntity(
val bankCode: BankCode,
val settlementStatus: String,
)
fun PaymentClientConfirmResponse.toTransferDetailEntity(
id: Long, id: Long,
paymentId: Long paymentId: Long
): PaymentBankTransferDetailEntity { ): PaymentBankTransferDetailEntity {
@ -116,3 +76,21 @@ fun PaymentClientConfirmResponse.toTransferDetailEntity(
settlementStatus = transferDetail.settlementStatus settlementStatus = transferDetail.settlementStatus
) )
} }
fun CancelDetail.toEntity(
id: Long,
paymentId: Long,
canceledBy: Long,
cancelRequestedAt: Instant
) = CanceledPaymentEntity(
id = id,
canceledAt = this.canceledAt.toInstant(),
requestedAt = cancelRequestedAt,
paymentId = paymentId,
canceledBy = canceledBy,
cancelReason = this.cancelReason,
cancelAmount = this.cancelAmount,
cardDiscountAmount = this.cardDiscountAmount,
transferDiscountAmount = this.transferDiscountAmount,
easypayDiscountAmount = this.easyPayDiscountAmount
)

View File

@ -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
)
}

View File

@ -5,6 +5,9 @@ import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.docs.PaymentAPI import com.sangdol.roomescape.payment.docs.PaymentAPI
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@ -15,12 +18,11 @@ class PaymentController(
private val paymentService: PaymentService private val paymentService: PaymentService
) : PaymentAPI { ) : PaymentAPI {
@PostMapping @PostMapping("/confirm")
override fun confirmPayment( override fun confirmPayment(
@RequestParam(required = true) reservationId: Long,
@Valid @RequestBody request: PaymentConfirmRequest @Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>> { ): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>> {
val response = paymentService.confirm(reservationId, request) val response = paymentService.requestConfirm(request)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }

View File

@ -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
)
}

View File

@ -1,9 +1,9 @@
package com.sangdol.roomescape.region.business package com.sangdol.roomescape.region.business
import com.sangdol.roomescape.region.dto.*
import com.sangdol.roomescape.region.exception.RegionErrorCode import com.sangdol.roomescape.region.exception.RegionErrorCode
import com.sangdol.roomescape.region.exception.RegionException import com.sangdol.roomescape.region.exception.RegionException
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
import com.sangdol.roomescape.region.web.*
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service

View File

@ -2,9 +2,9 @@ package com.sangdol.roomescape.region.docs
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.region.web.RegionCodeResponse import com.sangdol.roomescape.region.dto.RegionCodeResponse
import com.sangdol.roomescape.region.web.SidoListResponse import com.sangdol.roomescape.region.dto.SidoListResponse
import com.sangdol.roomescape.region.web.SigunguListResponse import com.sangdol.roomescape.region.dto.SigunguListResponse
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses

View File

@ -1,4 +1,4 @@
package com.sangdol.roomescape.region.web package com.sangdol.roomescape.region.dto
data class SidoResponse( data class SidoResponse(
val code: String, val code: String,

View File

@ -3,6 +3,9 @@ package com.sangdol.roomescape.region.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.region.business.RegionService import com.sangdol.roomescape.region.business.RegionService
import com.sangdol.roomescape.region.docs.RegionAPI import com.sangdol.roomescape.region.docs.RegionAPI
import com.sangdol.roomescape.region.dto.RegionCodeResponse
import com.sangdol.roomescape.region.dto.SidoListResponse
import com.sangdol.roomescape.region.dto.SigunguListResponse
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping

View File

@ -3,17 +3,22 @@ package com.sangdol.roomescape.reservation.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse import com.sangdol.roomescape.payment.dto.PaymentResponse
import com.sangdol.roomescape.reservation.dto.*
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.infrastructure.persistence.* import com.sangdol.roomescape.reservation.infrastructure.persistence.*
import com.sangdol.roomescape.reservation.web.* import com.sangdol.roomescape.reservation.mapper.toAdditionalResponse
import com.sangdol.roomescape.reservation.mapper.toEntity
import com.sangdol.roomescape.reservation.mapper.toOverviewResponse
import com.sangdol.roomescape.reservation.mapper.toStateResponse
import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.business.UserService
import com.sangdol.roomescape.user.web.UserContactResponse import com.sangdol.roomescape.user.dto.UserContactResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@ -42,11 +47,18 @@ class ReservationService(
): PendingReservationCreateResponse { ): PendingReservationCreateResponse {
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
validateCanCreate(request) run {
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
val theme = themeService.findInfoById(schedule.themeId)
val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id) reservationValidator.validateCanCreate(schedule, theme, request)
}
return PendingReservationCreateResponse(reservationRepository.save(reservation).id) val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id).also {
reservationRepository.save(it)
}
return PendingReservationCreateResponse(reservation.id)
.also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } } .also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } }
} }
@ -96,22 +108,30 @@ class ReservationService(
) )
return ReservationOverviewListResponse(reservations.map { return ReservationOverviewListResponse(reservations.map {
val schedule: ScheduleOverviewResponse = scheduleService.findScheduleOverviewById(it.scheduleId) val response: ScheduleWithThemeAndStoreResponse = scheduleService.findWithThemeAndStore(it.scheduleId)
it.toOverviewResponse(schedule) val schedule = response.schedule
it.toOverviewResponse(
scheduleDate = schedule.date,
scheduleStartFrom = schedule.startFrom,
scheduleEndAt = schedule.endAt,
storeName = response.theme.name,
themeName = response.store.name
)
}).also { }).also {
log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" } log.info { "[findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailById(id: Long): ReservationDetailResponse { fun findDetailById(id: Long): ReservationAdditionalResponse {
log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" } log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id) val reservation: ReservationEntity = findOrThrow(id)
val user: UserContactResponse = userService.findContactById(reservation.userId) val user: UserContactResponse = userService.findContactById(reservation.userId)
val paymentDetail: PaymentWithDetailResponse? = paymentService.findDetailByReservationId(id) val paymentDetail: PaymentResponse? = paymentService.findDetailByReservationId(id)
return reservation.toReservationDetailRetrieveResponse( return reservation.toAdditionalResponse(
user = user, user = user,
payment = paymentDetail payment = paymentDetail
).also { ).also {
@ -119,6 +139,30 @@ class ReservationService(
} }
} }
@Transactional(readOnly = true)
fun findStatusWithLock(id: Long): ReservationStateResponse {
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdForUpdate(id)?.let {
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" }
it.toStateResponse()
} ?: run {
log.warn { "[findStatusWithLock] 예약 LOCK + 상태 조회 실패: reservationId=${id}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
@Transactional
fun markInProgress(reservationId: Long) {
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
findOrThrow(reservationId).apply {
this.status = ReservationStatus.PAYMENT_IN_PROGRESS
}.also {
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 완료" }
}
}
private fun findOrThrow(id: Long): ReservationEntity { private fun findOrThrow(id: Long): ReservationEntity {
log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" } log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
@ -149,14 +193,6 @@ class ReservationService(
status = CanceledReservationStatus.COMPLETED status = CanceledReservationStatus.COMPLETED
).also { ).also {
canceledReservationRepository.save(it) canceledReservationRepository.save(it)
} }
} }
private fun validateCanCreate(request: PendingReservationCreateRequest) {
val schedule = scheduleService.findSummaryWithLock(request.scheduleId)
val theme = themeService.findInfoById(schedule.themeId)
reservationValidator.validateCanCreate(schedule, theme, request)
}
} }

View File

@ -1,14 +1,18 @@
package com.sangdol.roomescape.reservation.business package com.sangdol.roomescape.reservation.business
import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.common.utils.toKoreaDateTime
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleSummaryResponse import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import com.sangdol.roomescape.theme.web.ThemeInfoResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.Instant
import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -16,22 +20,35 @@ private val log: KLogger = KotlinLogging.logger {}
class ReservationValidator { class ReservationValidator {
fun validateCanCreate( fun validateCanCreate(
schedule: ScheduleSummaryResponse, schedule: ScheduleStateResponse,
theme: ThemeInfoResponse, theme: ThemeInfoResponse,
request: PendingReservationCreateRequest request: PendingReservationCreateRequest
) { ) {
validateSchedule(schedule)
validateReservationInfo(theme, request)
}
private fun validateSchedule(schedule: ScheduleStateResponse) {
if (schedule.status != ScheduleStatus.HOLD) { if (schedule.status != ScheduleStatus.HOLD) {
log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
} }
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
val nowDateTime = KoreaDateTime.now()
if (scheduleDateTime.isBefore(nowDateTime)) {
log.info { "[validateCanCreate] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}" }
throw ReservationException(ReservationErrorCode.PAST_SCHEDULE)
}
}
private fun validateReservationInfo(theme: ThemeInfoResponse, request: PendingReservationCreateRequest) {
if (theme.minParticipants > request.participantCount) { if (theme.minParticipants > request.participantCount) {
log.info { "[ReservationValidator.validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
} }
if (theme.maxParticipants < request.participantCount) { if (theme.maxParticipants < request.participantCount) {
log.info { "[ReservationValidator.validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } log.info { "[validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
} }
} }

View File

@ -24,20 +24,28 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional @Transactional
fun processExpiredHoldSchedule() { fun processExpiredHoldSchedule() {
log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
scheduleRepository.releaseExpiredHolds(Instant.now()).also { val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
}
scheduleRepository.releaseHeldSchedules(targets).also {
log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" }
} }
} }
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional @Transactional
fun processExpiredReservation() { fun processExpiredReservation() {
log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
reservationRepository.expirePendingReservations(Instant.now()).also { val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" } log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
}
reservationRepository.expirePendingReservations(Instant.now(), targets).also {
log.info { "[processExpiredReservation] ${it}개의 예약 및 일정 처리 완료" }
} }
} }
} }

View File

@ -4,7 +4,11 @@ import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.reservation.web.* import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse
import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
@ -47,5 +51,5 @@ interface ReservationAPI {
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findDetailById( fun findDetailById(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>> ): ResponseEntity<CommonApiResponse<ReservationAdditionalResponse>>
} }

View File

@ -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
)

View File

@ -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
)

View File

@ -12,6 +12,7 @@ enum class ReservationErrorCode(
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."), NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."), EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."),
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.") INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요."),
PAST_SCHEDULE(HttpStatus.BAD_REQUEST, "R006", "지난 일정은 예약할 수 없어요.")
; ;
} }

View File

@ -31,5 +31,5 @@ class ReservationEntity(
} }
enum class ReservationStatus { enum class ReservationStatus {
PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED PENDING, PAYMENT_IN_PROGRESS, CONFIRMED, CANCELED, EXPIRED;
} }

View File

@ -1,6 +1,8 @@
package com.sangdol.roomescape.reservation.infrastructure.persistence package com.sangdol.roomescape.reservation.infrastructure.persistence
import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
@ -10,6 +12,24 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity> fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
@Query("""
SELECT
r.id
FROM
reservation r
JOIN
schedule s ON r.schedule_id = s.id AND s.status = 'HOLD'
WHERE
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
fun findAllExpiredReservation(): List<Long>
@Modifying @Modifying
@Query( @Query(
""" """
@ -23,8 +43,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
s.status = 'AVAILABLE', s.status = 'AVAILABLE',
s.hold_expired_at = NULL s.hold_expired_at = NULL
WHERE WHERE
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) r.id IN :reservationIds
""", nativeQuery = true """, nativeQuery = true
) )
fun expirePendingReservations(@Param("now") now: Instant): Int fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int
} }

View File

@ -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
)

View File

@ -5,6 +5,11 @@ import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.reservation.business.ReservationService import com.sangdol.roomescape.reservation.business.ReservationService
import com.sangdol.roomescape.reservation.docs.ReservationAPI import com.sangdol.roomescape.reservation.docs.ReservationAPI
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse
import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@ -56,7 +61,7 @@ class ReservationController(
@GetMapping("/{id}/detail") @GetMapping("/{id}/detail")
override fun findDetailById( override fun findDetailById(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>> { ): ResponseEntity<CommonApiResponse<ReservationAdditionalResponse>> {
val response = reservationService.findDetailById(id) val response = reservationService.findDetailById(id)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))

View File

@ -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
)

View File

@ -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)
}
}
}

View File

@ -1,22 +1,21 @@
package com.sangdol.roomescape.schedule.business package com.sangdol.roomescape.schedule.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.utils.KoreaDate import com.sangdol.common.utils.KoreaDate
import com.sangdol.common.utils.KoreaTime import com.sangdol.common.utils.KoreaTime
import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.common.types.Auditor
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.exception.ScheduleException
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.* import com.sangdol.roomescape.schedule.mapper.toResponseWithTheme
import com.sangdol.roomescape.schedule.mapper.toResponseWithThemeAndStore
import com.sangdol.roomescape.schedule.mapper.toStateResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate import java.time.LocalDate
@ -24,25 +23,11 @@ import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
/**
* Structure:
* - Public: 모두가 접근 가능
* - User: 회원(로그인된 사용자) 사용 가능
* - All-Admin: 모든 관리자가 사용 가능
* - Store-Admin: 매장 관리자만 사용 가능
* - Other-Service: 다른 서비스에서 호출하는 메서드
* - Common: 공통 메서드
*/
@Service @Service
class ScheduleService( class ScheduleService(
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
private val scheduleValidator: ScheduleValidator, private val scheduleValidator: ScheduleValidator
private val idGenerator: IDGenerator,
private val adminService: AdminService
) { ) {
// ========================================
// Public (인증 불필요)
// ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse { fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" } log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
@ -59,127 +44,31 @@ class ScheduleService(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date) scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
.filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) } .filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
return schedules.toResponse() return schedules.toResponseWithTheme()
.also { .also {
log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" } log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" }
} }
} }
// ========================================
// User (회원 로그인 필요)
// ========================================
@Transactional @Transactional
fun holdSchedule(id: Long) { fun holdSchedule(id: Long) {
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" } log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
val result: Int = scheduleRepository.changeStatus(
id = id, val schedule = findForUpdateOrThrow(id).also {
currentStatus = ScheduleStatus.AVAILABLE, scheduleValidator.validateCanHold(it)
}
scheduleRepository.changeStatus(
id = schedule.id,
currentStatus = schedule.status,
changeStatus = ScheduleStatus.HOLD changeStatus = ScheduleStatus.HOLD
).also { ).also {
log.info { "[holdSchedule] $it 개의 row 변경 완료" } log.info { "[holdSchedule] 일정 Holding 완료: id=$id" }
} }
if (result == 0) {
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
}
log.info { "[holdSchedule] 일정 Holding 완료: id=$id" }
}
// ========================================
// All-Admin (본사, 매장 모두 사용가능)
// ========================================
@Transactional(readOnly = true)
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
val searchDate = date ?: KoreaDate.today()
val schedules: List<ScheduleOverview> =
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, searchDate)
.filter { (themeId == null) || (it.themeId == themeId) }
.sortedBy { it.time }
return schedules.toAdminSummaryListResponse()
.also {
log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findScheduleAudit(id: Long): AuditingInfo { fun findStateWithLock(id: Long): ScheduleStateResponse {
log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
val createdBy: Auditor = adminService.findOperatorOrUnknown(schedule.createdBy)
val updatedBy: Auditor = adminService.findOperatorOrUnknown(schedule.updatedBy)
return AuditingInfo(schedule.createdAt, createdBy, schedule.updatedAt, updatedBy)
.also { log.info { "[findDetail] 일정 감사 정보 조회 완료: id=$id" } }
}
// ========================================
// Store-Admin (매장 관리자 로그인 필요)
// ========================================
@Transactional
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
scheduleValidator.validateCanCreate(storeId, request)
val schedule = ScheduleEntityFactory.create(
id = idGenerator.create(),
date = request.date,
time = request.time,
storeId = storeId,
themeId = request.themeId
).also {
scheduleRepository.save(it)
}
return ScheduleCreateResponse(schedule.id)
.also {
log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
}
}
@Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
return
}
val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanUpdate(it, request)
}
schedule.modifyIfNotNull(request.time, request.status).also {
log.info { "[updateSchedule] 일정 수정 완료: id=$id, request=${request}" }
}
}
@Transactional
fun deleteSchedule(id: Long) {
log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanDelete(it)
}
scheduleRepository.delete(schedule).also {
log.info { "[deleteSchedule] 일정 삭제 완료: id=$id" }
}
}
// ========================================
// Other-Service (API 없이 다른 서비스에서 호출)
// ========================================
@Transactional(readOnly = true)
fun findSummaryWithLock(id: Long): ScheduleSummaryResponse {
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" } log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id) val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
@ -188,20 +77,20 @@ class ScheduleService(
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
} }
return schedule.toSummaryResponse() return schedule.toStateResponse()
.also { .also {
log.info { "[findDateTimeById] 일정 개요 조회 완료: id=$id" } log.info { "[findDateTimeById] 일정 개요 조회 완료: id=$id" }
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findScheduleOverviewById(id: Long): ScheduleOverviewResponse { fun findWithThemeAndStore(id: Long): ScheduleWithThemeAndStoreResponse {
val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run { val overview: ScheduleOverview = scheduleRepository.findOverviewByIdOrNull(id) ?: run {
log.warn { "[findScheduleOverview] 일정 개요 조회 실패: id=$id" } log.warn { "[findScheduleOverview] 일정 개요 조회 실패: id=$id" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
} }
return overview.toOverviewResponse() return overview.toResponseWithThemeAndStore()
} }
@Transactional @Transactional
@ -213,16 +102,13 @@ class ScheduleService(
} }
} }
// ======================================== private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
// Common (공통 메서드) log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
// ========================================
private fun findOrThrow(id: Long): ScheduleEntity {
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
return scheduleRepository.findByIdOrNull(id) return scheduleRepository.findByIdForUpdate(id)
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } } ?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }
?: run { ?: run {
log.warn { "[updateSchedule] 일정 조회 실패. id=$id" } log.warn { "[findForUpdateOrThrow] 일정 조회 실패. id=$id" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
} }
} }

View File

@ -6,8 +6,8 @@ import com.sangdol.roomescape.schedule.exception.ScheduleException
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@ -22,11 +22,20 @@ private val log: KLogger = KotlinLogging.logger {}
class ScheduleValidator( class ScheduleValidator(
private val scheduleRepository: ScheduleRepository private val scheduleRepository: ScheduleRepository
) { ) {
fun validateCanHold(schedule: ScheduleEntity) {
if (schedule.status != ScheduleStatus.AVAILABLE) {
log.info { "[validateCanHold] HOLD 실패: id=${schedule.id}, status=${schedule.status}" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
}
validateNotInPast(schedule.date, schedule.time)
}
fun validateCanDelete(schedule: ScheduleEntity) { fun validateCanDelete(schedule: ScheduleEntity) {
val status: ScheduleStatus = schedule.status val status: ScheduleStatus = schedule.status
if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) { if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) {
log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" } log.info { "[validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE) throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
} }
} }
@ -51,7 +60,7 @@ class ScheduleValidator(
private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) { private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) { if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
log.info { log.info {
"[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}" "[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
} }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS) throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
} }
@ -63,7 +72,7 @@ class ScheduleValidator(
if (inputDateTime.isBefore(now)) { if (inputDateTime.isBefore(now)) {
log.info { log.info {
"[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}" "[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
} }
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
} }
@ -73,7 +82,7 @@ class ScheduleValidator(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId) scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
.firstOrNull { it.containsTime(time) } .firstOrNull { it.containsTime(time) }
?.let { ?.let {
log.info { "[ScheduleValidator.validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" } log.info { "[validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT) throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT)
} }
} }

View File

@ -7,7 +7,11 @@ import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.UserOnly import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.schedule.web.* import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses

View File

@ -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>
)

View File

@ -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
}
}

View File

@ -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>
)

View File

@ -126,6 +126,26 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
expiredAt: Instant = Instant.now().plusSeconds(5 * 60) expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
): Int ): Int
@Modifying
@Query(
"""
SELECT
s.id
FROM
schedule s
WHERE
s.status = 'HOLD'
AND s.hold_expired_at <= :now
AND NOT EXISTS (
SELECT 1
FROM reservation r
WHERE r.schedule_id = s.id AND (r.status = 'PENDING' OR r.status = 'PAYMENT_IN_PROGRESS')
)
FOR UPDATE SKIP LOCKED
""", nativeQuery = true
)
fun findAllExpiredHeldSchedules(@Param("now") now: Instant): List<Long>
@Modifying @Modifying
@Query( @Query(
""" """
@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE, s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
s.holdExpiredAt = NULL s.holdExpiredAt = NULL
WHERE WHERE
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD s._id IN :scheduleIds
AND s.holdExpiredAt <= :now
AND NOT EXISTS (
SELECT 1
FROM ReservationEntity r
WHERE r.scheduleId = s._id
)
""" """
) )
fun releaseExpiredHolds(@Param("now") now: Instant): Int fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
} }

View File

@ -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() }
)

View File

@ -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() }
)

View File

@ -2,8 +2,13 @@ package com.sangdol.roomescape.schedule.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.schedule.business.AdminScheduleService
import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.format.annotation.DateTimeFormat import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@ -13,7 +18,7 @@ import java.time.LocalDate
@RestController @RestController
@RequestMapping("/admin") @RequestMapping("/admin")
class AdminScheduleController( class AdminScheduleController(
private val scheduleService: ScheduleService, private val adminScheduleService: AdminScheduleService,
) : AdminScheduleAPI { ) : AdminScheduleAPI {
@GetMapping("/stores/{storeId}/schedules") @GetMapping("/stores/{storeId}/schedules")
override fun searchSchedules( override fun searchSchedules(
@ -21,7 +26,7 @@ class AdminScheduleController(
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
@RequestParam(required = false) themeId: Long?, @RequestParam(required = false) themeId: Long?,
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> { ): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> {
val response = scheduleService.searchSchedules(storeId, date, themeId) val response = adminScheduleService.searchSchedules(storeId, date, themeId)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -30,7 +35,7 @@ class AdminScheduleController(
override fun findScheduleAudit( override fun findScheduleAudit(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<AuditingInfo>> { ): ResponseEntity<CommonApiResponse<AuditingInfo>> {
val response = scheduleService.findScheduleAudit(id) val response = adminScheduleService.findScheduleAudit(id)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -40,7 +45,7 @@ class AdminScheduleController(
@PathVariable("storeId") storeId: Long, @PathVariable("storeId") storeId: Long,
@Valid @RequestBody request: ScheduleCreateRequest @Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> { ): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
val response = scheduleService.createSchedule(storeId, request) val response = adminScheduleService.createSchedule(storeId, request)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -50,7 +55,7 @@ class AdminScheduleController(
@PathVariable("id") id: Long, @PathVariable("id") id: Long,
@Valid @RequestBody request: ScheduleUpdateRequest @Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.updateSchedule(id, request) adminScheduleService.updateSchedule(id, request)
return ResponseEntity.ok(CommonApiResponse(Unit)) return ResponseEntity.ok(CommonApiResponse(Unit))
} }
@ -59,7 +64,7 @@ class AdminScheduleController(
override fun deleteSchedule( override fun deleteSchedule(
@PathVariable("id") id: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.deleteSchedule(id) adminScheduleService.deleteSchedule(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }

View File

@ -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
}
}

View File

@ -4,6 +4,7 @@ import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.docs.PublicScheduleAPI import com.sangdol.roomescape.schedule.docs.PublicScheduleAPI
import com.sangdol.roomescape.schedule.docs.UserScheduleAPI import com.sangdol.roomescape.schedule.docs.UserScheduleAPI
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
import org.springframework.format.annotation.DateTimeFormat import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*

View File

@ -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,
)

View File

@ -4,12 +4,20 @@ import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.region.business.RegionService import com.sangdol.roomescape.region.business.RegionService
import com.sangdol.roomescape.store.dto.StoreDetailResponse
import com.sangdol.roomescape.store.dto.StoreNameListResponse
import com.sangdol.roomescape.store.dto.StoreInfoResponse
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
import com.sangdol.roomescape.store.dto.StoreRegisterResponse
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
import com.sangdol.roomescape.store.exception.StoreErrorCode import com.sangdol.roomescape.store.exception.StoreErrorCode
import com.sangdol.roomescape.store.exception.StoreException import com.sangdol.roomescape.store.exception.StoreException
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus
import com.sangdol.roomescape.store.web.* import com.sangdol.roomescape.store.mapper.toDetailResponse
import com.sangdol.roomescape.store.mapper.toInfoResponse
import com.sangdol.roomescape.store.mapper.toSimpleListResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -26,7 +34,7 @@ class StoreService(
private val idGenerator: IDGenerator, private val idGenerator: IDGenerator,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getDetail(id: Long): DetailStoreResponse { fun getDetail(id: Long): StoreDetailResponse {
log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" } log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id) val store: StoreEntity = findOrThrow(id)
@ -85,7 +93,7 @@ class StoreService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): SimpleStoreListResponse { fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse {
log.info { "[getAllActiveStores] 전체 매장 조회 시작" } log.info { "[getAllActiveStores] 전체 매장 조회 시작" }
val regionCode: String? = when { val regionCode: String? = when {

View File

@ -3,8 +3,8 @@ package com.sangdol.roomescape.store.business
import com.sangdol.roomescape.store.exception.StoreErrorCode import com.sangdol.roomescape.store.exception.StoreErrorCode
import com.sangdol.roomescape.store.exception.StoreException import com.sangdol.roomescape.store.exception.StoreException
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.store.web.StoreRegisterRequest import com.sangdol.roomescape.store.dto.StoreRegisterRequest
import com.sangdol.roomescape.store.web.StoreUpdateRequest import com.sangdol.roomescape.store.dto.StoreUpdateRequest
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component

View File

@ -5,7 +5,12 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.store.web.* import com.sangdol.roomescape.store.dto.StoreDetailResponse
import com.sangdol.roomescape.store.dto.StoreNameListResponse
import com.sangdol.roomescape.store.dto.StoreInfoResponse
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
import com.sangdol.roomescape.store.dto.StoreRegisterResponse
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
@ -21,7 +26,7 @@ interface AdminStoreAPI {
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findStoreDetail( fun findStoreDetail(
@PathVariable id: Long @PathVariable id: Long
): ResponseEntity<CommonApiResponse<DetailStoreResponse>> ): ResponseEntity<CommonApiResponse<StoreDetailResponse>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
@Operation(summary = "매장 등록") @Operation(summary = "매장 등록")
@ -53,7 +58,7 @@ interface PublicStoreAPI {
fun getStores( fun getStores(
@RequestParam(value = "sido", required = false) sidoCode: String?, @RequestParam(value = "sido", required = false) sidoCode: String?,
@RequestParam(value = "sigungu", required = false) sigunguCode: String? @RequestParam(value = "sigungu", required = false) sigunguCode: String?
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>> ): ResponseEntity<CommonApiResponse<StoreNameListResponse>>
@Public @Public
@Operation(summary = "특정 매장의 정보 조회") @Operation(summary = "특정 매장의 정보 조회")

View File

@ -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,
)

View File

@ -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
)

View File

@ -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) }
)

View File

@ -3,6 +3,10 @@ package com.sangdol.roomescape.store.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.store.business.StoreService import com.sangdol.roomescape.store.business.StoreService
import com.sangdol.roomescape.store.docs.AdminStoreAPI import com.sangdol.roomescape.store.docs.AdminStoreAPI
import com.sangdol.roomescape.store.dto.StoreDetailResponse
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
import com.sangdol.roomescape.store.dto.StoreRegisterResponse
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@ -16,8 +20,8 @@ class AdminStoreController(
@GetMapping("/{id}/detail") @GetMapping("/{id}/detail")
override fun findStoreDetail( override fun findStoreDetail(
@PathVariable id: Long @PathVariable id: Long
): ResponseEntity<CommonApiResponse<DetailStoreResponse>> { ): ResponseEntity<CommonApiResponse<StoreDetailResponse>> {
val response: DetailStoreResponse = storeService.getDetail(id) val response: StoreDetailResponse = storeService.getDetail(id)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }

View File

@ -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,
)

View File

@ -3,6 +3,8 @@ package com.sangdol.roomescape.store.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.store.business.StoreService import com.sangdol.roomescape.store.business.StoreService
import com.sangdol.roomescape.store.docs.PublicStoreAPI import com.sangdol.roomescape.store.docs.PublicStoreAPI
import com.sangdol.roomescape.store.dto.StoreNameListResponse
import com.sangdol.roomescape.store.dto.StoreInfoResponse
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@ -18,7 +20,7 @@ class StoreController(
override fun getStores( override fun getStores(
@RequestParam(value = "sido", required = false) sidoCode: String?, @RequestParam(value = "sido", required = false) sidoCode: String?,
@RequestParam(value = "sigungu", required = false) sigunguCode: String? @RequestParam(value = "sigungu", required = false) sigunguCode: String?
): ResponseEntity<CommonApiResponse<SimpleStoreListResponse>> { ): ResponseEntity<CommonApiResponse<StoreNameListResponse>> {
val response = storeService.getAllActiveStores(sidoCode, sigunguCode) val response = storeService.getAllActiveStores(sidoCode, sigunguCode)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))

View File

@ -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
)

View File

@ -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)
}
}
}

View File

@ -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))
}

View File

@ -1,44 +1,38 @@
package com.sangdol.roomescape.theme.business package com.sangdol.roomescape.theme.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.utils.KoreaDate import com.sangdol.common.utils.KoreaDate
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse
import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.exception.ThemeException import com.sangdol.roomescape.theme.exception.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.web.* import com.sangdol.roomescape.theme.mapper.toInfoResponse
import com.sangdol.roomescape.theme.mapper.toListResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.TemporalAdjusters
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
/**
* Structure:
* - Public: 모두가 접근 가능한 메서드
* - Store Admin: 매장 관리자가 사용하는 메서드
* - HQ Admin: 본사 관리자가 사용하는 메서드
* - Common: 공통 메서드
*/
@Service @Service
class ThemeService( class ThemeService(
private val themeRepository: ThemeRepository, private val themeRepository: ThemeRepository
private val themeValidator: ThemeValidator,
private val idGenerator: IDGenerator,
private val adminService: AdminService
) { ) {
// ========================================
// Public (인증 불필요)
// ========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findInfoById(id: Long): ThemeInfoResponse { fun findInfoById(id: Long): ThemeInfoResponse {
log.info { "[findInfoById] 테마 조회 시작: id=$id" } log.info { "[findInfoById] 테마 조회 시작: id=$id" }
return findOrThrow(id).toInfoResponse() val theme = themeRepository.findByIdOrNull(id) ?: run {
log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
return theme.toInfoResponse()
.also { log.info { "[findInfoById] 테마 조회 완료: id=$id" } } .also { log.info { "[findInfoById] 테마 조회 완료: id=$id" } }
} }
@ -54,115 +48,11 @@ class ThemeService(
.also { .also {
log.info { "[findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" } log.info { "[findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" }
} }
}
// ========================================
// HQ Admin (본사)
// ========================================
@Transactional(readOnly = true)
fun findAdminThemes(): AdminThemeSummaryListResponse {
log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findAll()
.toAdminThemeSummaryListResponse()
.also { log.info { "[findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
}
@Transactional(readOnly = true)
fun findAdminThemeDetail(id: Long): AdminThemeDetailResponse {
log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id)
val createdBy = adminService.findOperatorOrUnknown(theme.createdBy)
val updatedBy = adminService.findOperatorOrUnknown(theme.updatedBy)
val audit = AuditingInfo(theme.createdAt, createdBy, theme.updatedAt, updatedBy)
return theme.toAdminThemeDetailResponse(audit)
.also { log.info { "[findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
}
@Transactional
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.info { "[createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request)
val theme: ThemeEntity = request.toEntity(id = idGenerator.create())
.also { themeRepository.save(it) }
return ThemeCreateResponse(theme.id).also {
log.info { "[createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
}
}
@Transactional
fun deleteTheme(id: Long) {
log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id)
themeRepository.delete(theme).also {
log.info { "[deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
}
}
@Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
return
}
themeValidator.validateCanUpdate(request)
val theme: ThemeEntity = findOrThrow(id)
theme.modifyIfNotNull(
request.name,
request.description,
request.thumbnailUrl,
request.difficulty,
request.price,
request.minParticipants,
request.maxParticipants,
request.availableMinutes,
request.expectedMinutesFrom,
request.expectedMinutesTo,
request.isActive,
).also {
log.info { "[updateTheme] 테마 수정 완료: id=$id, request=${request}" }
}
}
// ========================================
// Store Admin (매장)
// ========================================
@Transactional(readOnly = true)
fun findActiveThemes(): SimpleActiveThemeListResponse {
log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
return themeRepository.findActiveThemes()
.toSimpleActiveThemeResponse()
.also {
log.info { "[findActiveThemes] ${it.themes.size}개 테마 조회 완료" }
}
}
// ========================================
// Common (공통 메서드)
// ========================================
private fun findOrThrow(id: Long): ThemeEntity {
log.info { "[findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }
?: run {
log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
} }
} }
object DateUtils {
fun getSundayOfPreviousWeek(date: LocalDate): LocalDate = date
.minusWeeks(1)
.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
}

View File

@ -3,8 +3,8 @@ package com.sangdol.roomescape.theme.business
import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.exception.ThemeException import com.sangdol.roomescape.theme.exception.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.web.ThemeCreateRequest import com.sangdol.roomescape.theme.dto.ThemeCreateRequest
import com.sangdol.roomescape.theme.web.ThemeUpdateRequest import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component

View File

@ -5,7 +5,14 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
import com.sangdol.roomescape.auth.web.support.AdminOnly import com.sangdol.roomescape.auth.web.support.AdminOnly
import com.sangdol.roomescape.auth.web.support.Public import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.theme.web.* import com.sangdol.roomescape.theme.dto.ThemeDetailResponse
import com.sangdol.roomescape.theme.dto.ThemeSummaryListResponse
import com.sangdol.roomescape.theme.dto.ThemeNameListResponse
import com.sangdol.roomescape.theme.dto.ThemeCreateRequest
import com.sangdol.roomescape.theme.dto.ThemeCreateResponse
import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
@ -20,12 +27,12 @@ interface AdminThemeAPI {
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY) @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_SUMMARY)
@Operation(summary = "모든 테마 조회") @Operation(summary = "모든 테마 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>> fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL) @AdminOnly(type = AdminType.HQ, privilege = Privilege.READ_DETAIL)
@Operation(summary = "테마 상세 조회") @Operation(summary = "테마 상세 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>> fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity<CommonApiResponse<ThemeDetailResponse>>
@AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE) @AdminOnly(type = AdminType.HQ, privilege = Privilege.CREATE)
@Operation(summary = "테마 추가") @Operation(summary = "테마 추가")
@ -48,7 +55,7 @@ interface AdminThemeAPI {
@AdminOnly(privilege = Privilege.READ_SUMMARY) @AdminOnly(privilege = Privilege.READ_SUMMARY)
@Operation(summary = "현재 활성화 상태인 테마 ID + 이름 목록 조회") @Operation(summary = "현재 활성화 상태인 테마 ID + 이름 목록 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun getActiveThemes(): ResponseEntity<CommonApiResponse<SimpleActiveThemeListResponse>> fun getActiveThemes(): ResponseEntity<CommonApiResponse<ThemeNameListResponse>>
} }
interface PublicThemeAPI { interface PublicThemeAPI {

View File

@ -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>
)

View File

@ -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
}
}

View File

@ -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>
)

View File

@ -32,10 +32,9 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
AND (s.date BETWEEN :startFrom AND :endAt) AND (s.date BETWEEN :startFrom AND :endAt)
GROUP BY GROUP BY
s.theme_id s.theme_id
ORDER BY
reservation_count desc
LIMIT :count LIMIT :count
) ranked_themes ON t.id = ranked_themes.theme_id ) ranked_themes ON t.id = ranked_themes.theme_id
ORDER BY ranked_themes.reservation_count DESC, t.id ASC
""", """,
nativeQuery = true nativeQuery = true
) )

View File

@ -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() }
)

View File

@ -1,23 +1,11 @@
package com.sangdol.roomescape.theme.web package com.sangdol.roomescape.theme.mapper
import com.sangdol.roomescape.theme.business.domain.ThemeInfo import com.sangdol.roomescape.theme.business.domain.ThemeInfo
import com.sangdol.roomescape.theme.dto.ThemeInfoListResponse
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
data class ThemeInfoResponse( fun ThemeInfo.toResponse() = ThemeInfoResponse(
val id: Long,
val name: String,
val thumbnailUrl: String,
val description: String,
val difficulty: String,
val price: Int,
val minParticipants: Short,
val maxParticipants: Short,
val availableMinutes: Short,
val expectedMinutesFrom: Short,
val expectedMinutesTo: Short
)
fun ThemeInfo.toInfoResponse() = ThemeInfoResponse(
id = this.id, id = this.id,
name = this.name, name = this.name,
thumbnailUrl = this.thumbnailUrl, thumbnailUrl = this.thumbnailUrl,
@ -45,10 +33,8 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
expectedMinutesTo = this.expectedMinutesTo expectedMinutesTo = this.expectedMinutesTo
) )
data class ThemeInfoListResponse( fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
val themes: List<ThemeInfoResponse> themes = this.map { it.toResponse() }
) )
fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
themes = this.map { it.toInfoResponse() }
)

View File

@ -1,8 +1,9 @@
package com.sangdol.roomescape.theme.web package com.sangdol.roomescape.theme.web
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.theme.business.AdminThemeService
import com.sangdol.roomescape.theme.docs.AdminThemeAPI import com.sangdol.roomescape.theme.docs.AdminThemeAPI
import com.sangdol.roomescape.theme.dto.*
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@ -10,19 +11,19 @@ import java.net.URI
@RestController @RestController
class AdminThemeController( class AdminThemeController(
private val themeService: ThemeService, private val adminThemeService: AdminThemeService,
) : AdminThemeAPI { ) : AdminThemeAPI {
@GetMapping("/admin/themes") @GetMapping("/admin/themes")
override fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<AdminThemeSummaryListResponse>> { override fun getAdminThemeSummaries(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>> {
val response = themeService.findAdminThemes() val response = adminThemeService.findThemeSummaries()
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@GetMapping("/admin/themes/{id}") @GetMapping("/admin/themes/{id}")
override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity<CommonApiResponse<AdminThemeDetailResponse>> { override fun findAdminThemeDetail(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeDetailResponse>> {
val response = themeService.findAdminThemeDetail(id) val response = adminThemeService.findThemeDetail(id)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -31,7 +32,7 @@ class AdminThemeController(
override fun createTheme( override fun createTheme(
@Valid @RequestBody themeCreateRequest: ThemeCreateRequest @Valid @RequestBody themeCreateRequest: ThemeCreateRequest
): ResponseEntity<CommonApiResponse<ThemeCreateResponse>> { ): ResponseEntity<CommonApiResponse<ThemeCreateResponse>> {
val response = themeService.createTheme(themeCreateRequest) val response = adminThemeService.createTheme(themeCreateRequest)
return ResponseEntity.created(URI.create("/admin/themes/${response.id}")) return ResponseEntity.created(URI.create("/admin/themes/${response.id}"))
.body(CommonApiResponse(response)) .body(CommonApiResponse(response))
@ -39,7 +40,7 @@ class AdminThemeController(
@DeleteMapping("/admin/themes/{id}") @DeleteMapping("/admin/themes/{id}")
override fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> { override fun deleteTheme(@PathVariable id: Long): ResponseEntity<CommonApiResponse<Unit>> {
themeService.deleteTheme(id) adminThemeService.deleteTheme(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@ -49,14 +50,14 @@ class AdminThemeController(
@PathVariable id: Long, @PathVariable id: Long,
@Valid @RequestBody request: ThemeUpdateRequest @Valid @RequestBody request: ThemeUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
themeService.updateTheme(id, request) adminThemeService.updateTheme(id, request)
return ResponseEntity.ok().build() return ResponseEntity.ok().build()
} }
@GetMapping("/admin/themes/active") @GetMapping("/admin/themes/active")
override fun getActiveThemes(): ResponseEntity<CommonApiResponse<SimpleActiveThemeListResponse>> { override fun getActiveThemes(): ResponseEntity<CommonApiResponse<ThemeNameListResponse>> {
val response = themeService.findActiveThemes() val response = adminThemeService.findActiveThemes()
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }

Some files were not shown because too many files have changed in this diff Show More