generated from pricelees/issue-pr-template
Compare commits
11 Commits
refactor/#
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e572c842c | |||
| 7a236a8196 | |||
| 0756e21b63 | |||
| 162e5bbc79 | |||
| be2e6c606e | |||
| 06f7faf7f9 | |||
| 79de5c9c63 | |||
| 5f2e44bb11 | |||
| bba3266f3f | |||
| 135b13a9bf | |||
| 047e4a395b |
28
Dockerfile
28
Dockerfile
@ -1,29 +1,9 @@
|
|||||||
FROM gradle:8-jdk17 AS dependencies
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY gradlew settings.gradle build.gradle.kts /app/
|
|
||||||
COPY gradle /app/gradle
|
|
||||||
COPY service/build.gradle.kts /app/service/
|
|
||||||
COPY tosspay-mock/build.gradle.kts /app/tosspay-mock/
|
|
||||||
COPY common/log/build.gradle.kts /app/common/log/
|
|
||||||
COPY common/persistence/build.gradle.kts /app/common/persistence/
|
|
||||||
COPY common/types/build.gradle.kts /app/common/types/
|
|
||||||
COPY common/utils/build.gradle.kts /app/common/utils/
|
|
||||||
COPY common/web/build.gradle.kts /app/common/web/
|
|
||||||
|
|
||||||
RUN ./gradlew dependencies --no-daemon
|
|
||||||
|
|
||||||
FROM dependencies AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN ./gradlew :service:bootjar --no-daemon
|
|
||||||
|
|
||||||
FROM amazoncorretto:17
|
FROM amazoncorretto:17
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY service/build/libs/service.jar app.jar
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
COPY --from=builder /app/service/build/libs/*.jar app.jar
|
|
||||||
|
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
6
build.sh
Executable file
6
build.sh
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
IMAGE_NAME="roomescape-backend"
|
||||||
|
IMAGE_TAG=$1
|
||||||
|
|
||||||
|
./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push
|
||||||
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
|||||||
import com.sangdol.common.web.asepct.ControllerLoggingAspect
|
import com.sangdol.common.web.asepct.ControllerLoggingAspect
|
||||||
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
|
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
|
||||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
|
import io.micrometer.tracing.CurrentTraceContext
|
||||||
import org.springframework.boot.web.servlet.FilterRegistrationBean
|
import org.springframework.boot.web.servlet.FilterRegistrationBean
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
@ -17,9 +18,10 @@ class WebLoggingConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
@DependsOn(value = ["webLogMessageConverter"])
|
@DependsOn(value = ["webLogMessageConverter"])
|
||||||
fun filterRegistrationBean(
|
fun filterRegistrationBean(
|
||||||
webLogMessageConverter: WebLogMessageConverter
|
webLogMessageConverter: WebLogMessageConverter,
|
||||||
|
currentTraceContext: CurrentTraceContext
|
||||||
): FilterRegistrationBean<OncePerRequestFilter> {
|
): FilterRegistrationBean<OncePerRequestFilter> {
|
||||||
val filter = HttpRequestLoggingFilter(webLogMessageConverter)
|
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
|
||||||
|
|
||||||
return FilterRegistrationBean<OncePerRequestFilter>(filter)
|
return FilterRegistrationBean<OncePerRequestFilter>(filter)
|
||||||
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
|
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
|
||||||
|
|||||||
@ -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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.sangdol.common.utils.MdcStartTimeUtil
|
|||||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import io.micrometer.tracing.CurrentTraceContext
|
||||||
import jakarta.servlet.FilterChain
|
import jakarta.servlet.FilterChain
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
@ -15,7 +16,8 @@ import org.springframework.web.util.ContentCachingResponseWrapper
|
|||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
class HttpRequestLoggingFilter(
|
class HttpRequestLoggingFilter(
|
||||||
private val messageConverter: WebLogMessageConverter
|
private val messageConverter: WebLogMessageConverter,
|
||||||
|
private val currentTraceContext: CurrentTraceContext
|
||||||
) : OncePerRequestFilter() {
|
) : OncePerRequestFilter() {
|
||||||
override fun doFilterInternal(
|
override fun doFilterInternal(
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
@ -32,9 +34,12 @@ class HttpRequestLoggingFilter(
|
|||||||
try {
|
try {
|
||||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
filterChain.doFilter(cachedRequest, cachedResponse)
|
||||||
cachedResponse.copyBodyToResponse()
|
cachedResponse.copyBodyToResponse()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
MdcStartTimeUtil.clear()
|
MdcStartTimeUtil.clear()
|
||||||
MdcPrincipalIdUtil.clear()
|
MdcPrincipalIdUtil.clear()
|
||||||
|
currentTraceContext.maybeScope(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.sangdol.common.web.support.log
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.sangdol.common.log.constant.LogType
|
import com.sangdol.common.log.constant.LogType
|
||||||
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
class WebLogMessageConverter(
|
class WebLogMessageConverter(
|
||||||
@ -49,4 +50,19 @@ class WebLogMessageConverter(
|
|||||||
|
|
||||||
return objectMapper.writeValueAsString(payload)
|
return objectMapper.writeValueAsString(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun convertToErrorResponseMessage(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
httpStatus: HttpStatus,
|
||||||
|
responseBody: Any? = null,
|
||||||
|
exception: Exception? = null,
|
||||||
|
): String {
|
||||||
|
val type = if (httpStatus.isClientError()) {
|
||||||
|
LogType.APPLICATION_FAILURE
|
||||||
|
} else {
|
||||||
|
LogType.UNHANDLED_EXCEPTION
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -168,5 +168,27 @@ class WebLogMessageConverterTest : FunSpec({
|
|||||||
this["exception"] shouldBe null
|
this["exception"] shouldBe null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") {
|
||||||
|
val result = converter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
|
httpStatus = HttpStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
|
||||||
|
this["type"] shouldBe LogType.APPLICATION_FAILURE.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") {
|
||||||
|
val result = converter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
|
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
|
||||||
|
this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
12
frontend/src/api/order/orderAPI.ts
Normal file
12
frontend/src/api/order/orderAPI.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import apiClient from "@_api/apiClient";
|
||||||
|
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
|
||||||
|
|
||||||
|
export const confirm = async (
|
||||||
|
reservationId: string,
|
||||||
|
data: PaymentConfirmRequest,
|
||||||
|
): Promise<void> => {
|
||||||
|
return await apiClient.post<void>(
|
||||||
|
`/orders/${reservationId}/confirm`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
};
|
||||||
5
frontend/src/api/order/orderTypes.ts
Normal file
5
frontend/src/api/order/orderTypes.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface OrderErrorResponse {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
trial: number;
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
|
|||||||
paymentKey: string;
|
paymentKey: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
paymentType: PaymentType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentCancelRequest {
|
export interface PaymentCancelRequest {
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import type { Difficulty } from '@_api/theme/themeTypes';
|
|
||||||
|
|
||||||
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
|
||||||
|
|
||||||
export const ScheduleStatus = {
|
export const ScheduleStatus = {
|
||||||
@ -40,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 {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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 { type ReservationData } from '@_api/reservation/reservationTypes';
|
||||||
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
|
||||||
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
|
||||||
import { getStores } from '@_api/store/storeAPI';
|
import { getStores } from '@_api/store/storeAPI';
|
||||||
@ -10,7 +11,6 @@ import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeType
|
|||||||
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 = () => {
|
||||||
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
fetchSchedules(selectedStore.id, dateStr)
|
fetchSchedules(selectedStore.id, dateStr)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const grouped = res.schedules.reduce((acc, schedule) => {
|
const grouped = res.schedules.reduce((acc, schedule) => {
|
||||||
const key = schedule.themeName;
|
const key = schedule.theme.name;
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
acc[key].push(schedule);
|
acc[key].push(schedule);
|
||||||
return acc;
|
return acc;
|
||||||
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
const handleConfirmReservation = () => {
|
const handleConfirmReservation = () => {
|
||||||
if (!selectedSchedule) return;
|
if (!selectedSchedule) return;
|
||||||
|
|
||||||
holdSchedule(selectedSchedule.id)
|
holdSchedule(selectedSchedule.schedule.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
fetchThemeById(selectedSchedule.themeId).then(res => {
|
fetchThemeById(selectedSchedule.theme.id).then(res => {
|
||||||
const reservationData: ReservationData = {
|
const reservationData: ReservationData = {
|
||||||
scheduleId: selectedSchedule.id,
|
scheduleId: selectedSchedule.schedule.id,
|
||||||
store: {
|
store: {
|
||||||
id: selectedStore!.id,
|
id: selectedStore!.id,
|
||||||
name: selectedStore!.name,
|
name: selectedStore!.name,
|
||||||
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
maxParticipants: res.maxParticipants,
|
maxParticipants: res.maxParticipants,
|
||||||
},
|
},
|
||||||
date: selectedDate.toLocaleDateString('en-CA'),
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
startFrom: selectedSchedule.startFrom,
|
startFrom: selectedSchedule.schedule.startFrom,
|
||||||
endAt: selectedSchedule.endAt,
|
endAt: selectedSchedule.schedule.endAt,
|
||||||
};
|
};
|
||||||
navigate('/reservation/form', {state: reservationData});
|
navigate('/reservation/form', {state: reservationData});
|
||||||
}).catch(handleError);
|
}).catch(handleError);
|
||||||
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<h3>3. 시간 선택</h3>
|
<h3>3. 시간 선택</h3>
|
||||||
<div className="schedule-list">
|
<div className="schedule-list">
|
||||||
{Object.keys(schedulesByTheme).length > 0 ? (
|
{Object.keys(schedulesByTheme).length > 0 ? (
|
||||||
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
|
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
|
||||||
<div key={themeName} className="theme-schedule-group">
|
<div key={themeName} className="theme-schedule-group">
|
||||||
<div className="theme-header">
|
<div className="theme-header">
|
||||||
<h4>{themeName}</h4>
|
<h4>{themeName}</h4>
|
||||||
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
|
||||||
className="theme-detail-button">상세보기
|
className="theme-detail-button">상세보기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="time-slots">
|
<div className="time-slots">
|
||||||
{schedules.map(schedule => (
|
{scheduleAndTheme.map(schedule => (
|
||||||
<div
|
<div
|
||||||
key={schedule.id}
|
key={schedule.schedule.id}
|
||||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||||
>
|
>
|
||||||
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
|
||||||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
<div className="modal-section modal-info-grid">
|
<div className="modal-section modal-info-grid">
|
||||||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||||||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||||||
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
<p><strong>테마:</strong><span>{selectedSchedule.theme.name}</span></p>
|
||||||
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
<p><strong>시간:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { confirm } from '@_api/order/orderAPI';
|
||||||
import { confirmPayment } from '@_api/payment/paymentAPI';
|
import type { OrderErrorResponse } 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<OrderErrorResponse>;
|
||||||
|
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("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
845
query.md
845
query.md
@ -1,845 +0,0 @@
|
|||||||
## Auth
|
|
||||||
|
|
||||||
**로그인**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 회원
|
|
||||||
|
|
||||||
-- 이메일로 회원 조회
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.email = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 연락처로 회원 조회
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.phone = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 회원 추가
|
|
||||||
INSERT INTO users (
|
|
||||||
created_at, created_by, email, name, password, phone, region_code,
|
|
||||||
status, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 회원 상태 이력 추가
|
|
||||||
INSERT INTO user_status_history (
|
|
||||||
created_at, created_by, reason, status, updated_at, updated_by,
|
|
||||||
user_id, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Payment
|
|
||||||
|
|
||||||
**결제 승인 & 저장**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 결제 정보 추가
|
|
||||||
INSERT INTO payment ( approved_at, method, order_id, payment_key, requested_at, reservation_id, status, total_amount, type, id
|
|
||||||
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 결제 상세 정보 추가
|
|
||||||
INSERT INTO payment_detail ( payment_id, supplied_amount, vat, id
|
|
||||||
) VALUES ( ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
-- 카드 결제 상세 정보 추가
|
|
||||||
INSERT INTO payment_card_detail ( amount, approval_number, card_number, card_type, easypay_discount_amount, easypay_provider_code, installment_plan_months, is_interest_free, issuer_code, owner_type, id
|
|
||||||
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**결제 취소**
|
|
||||||
|
|
||||||
SQL
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 ID로 결제 정보 조회
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.approved_at,
|
|
||||||
p.method,
|
|
||||||
p.order_id,
|
|
||||||
p.payment_key,
|
|
||||||
p.requested_at,
|
|
||||||
p.reservation_id,
|
|
||||||
p.status,
|
|
||||||
p.total_amount,
|
|
||||||
p.type
|
|
||||||
FROM
|
|
||||||
payment p
|
|
||||||
WHERE
|
|
||||||
p.reservation_id = ?;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
-- 취소된 결제 정보 추가
|
|
||||||
INSERT INTO canceled_payment (
|
|
||||||
cancel_amount, cancel_reason, canceled_at, canceled_by,
|
|
||||||
card_discount_amount, easypay_discount_amount, payment_id,
|
|
||||||
requested_at, transfer_discount_amount, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Region
|
|
||||||
|
|
||||||
**모든 시/도 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT DISTINCT
|
|
||||||
r.sido_code,
|
|
||||||
r.sido_name
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
ORDER BY
|
|
||||||
r.sido_name;
|
|
||||||
```
|
|
||||||
|
|
||||||
**시/군/구 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
r.sigungu_code,
|
|
||||||
r.sigungu_name
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
WHERE
|
|
||||||
r.sido_code = ?
|
|
||||||
GROUP BY
|
|
||||||
r.sigungu_code, r.sigungu_name
|
|
||||||
ORDER BY
|
|
||||||
r.sigungu_name;
|
|
||||||
```
|
|
||||||
|
|
||||||
**지역 코드 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
r.code
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
WHERE
|
|
||||||
r.sido_code = ? AND r.sigungu_code = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reservation
|
|
||||||
|
|
||||||
**Pending 예약 생성**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- schedule 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- theme 조회
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
|
|
||||||
-- 예약 추가
|
|
||||||
INSERT INTO reservation (
|
|
||||||
created_at, created_by, participant_count, requirement,
|
|
||||||
reserver_contact, reserver_name, schedule_id, status,
|
|
||||||
updated_at, updated_by, user_id, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**확정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.id = ?;
|
|
||||||
|
|
||||||
-- 일정 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 예약 확정
|
|
||||||
UPDATE
|
|
||||||
reservation
|
|
||||||
SET
|
|
||||||
participant_count = ?, requirement = ?, reserver_contact = ?,
|
|
||||||
reserver_name = ?, schedule_id = ?, status = ?,
|
|
||||||
updated_at = ?, updated_by = ?, user_id = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
|
|
||||||
-- Schedule 확정
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**취소**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.id = ?;
|
|
||||||
|
|
||||||
-- 일정 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 취소 예약 추가
|
|
||||||
INSERT INTO canceled_reservation (
|
|
||||||
cancel_reason, canceled_at, canceled_by,
|
|
||||||
reservation_id, status, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 예약 취소
|
|
||||||
UPDATE
|
|
||||||
reservation
|
|
||||||
SET
|
|
||||||
participant_count = ?, requirement = ?, reserver_contact = ?,
|
|
||||||
reserver_name = ?, schedule_id = ?, status = ?,
|
|
||||||
updated_at = ?, updated_by = ?, user_id = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
|
|
||||||
-- 일정 활성화
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**회원 예약 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.user_id = ? AND r.status IN (?, ?);
|
|
||||||
|
|
||||||
-- 일정 조회 -> 각 예약별 1개씩(N개)
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
st.id AS store_id,
|
|
||||||
st.name AS store_name,
|
|
||||||
s.date,
|
|
||||||
s.time,
|
|
||||||
t.id AS theme_id,
|
|
||||||
t.name AS theme_name,
|
|
||||||
t.difficulty,
|
|
||||||
t.available_minutes,
|
|
||||||
s.status
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN theme t ON t.id = s.theme_id
|
|
||||||
JOIN store st ON st.id = s.store_id
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**예약 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 예약 조회
|
|
||||||
SELECT
|
|
||||||
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
|
|
||||||
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
|
|
||||||
r.updated_at, r.updated_by, r.user_id
|
|
||||||
FROM
|
|
||||||
reservation r
|
|
||||||
WHERE
|
|
||||||
r.id = ?;
|
|
||||||
|
|
||||||
-- 회원 연락처 정보 조회
|
|
||||||
SELECT
|
|
||||||
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
|
|
||||||
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.id = ?;
|
|
||||||
|
|
||||||
-- 결제 정보 조회
|
|
||||||
SELECT
|
|
||||||
p.id, p.approved_at, p.method, p.order_id, p.payment_key,
|
|
||||||
p.requested_at, p.reservation_id, p.status, p.total_amount, p.type
|
|
||||||
FROM
|
|
||||||
payment p
|
|
||||||
WHERE
|
|
||||||
p.reservation_id = ?;
|
|
||||||
|
|
||||||
-- 결제 상세 정보 조회
|
|
||||||
SELECT
|
|
||||||
pd.id,
|
|
||||||
CASE
|
|
||||||
WHEN pbt.id IS NOT NULL THEN 1 -- bank_transfer
|
|
||||||
WHEN pcd.id IS NOT NULL THEN 2 -- card
|
|
||||||
WHEN pep.id IS NOT NULL THEN 3 -- easypay
|
|
||||||
WHEN pd.id IS NOT NULL THEN 0 -- etc
|
|
||||||
END AS payment_type,
|
|
||||||
pd.payment_id, pd.supplied_amount, pd.vat,
|
|
||||||
pbt.bank_code, pbt.settlement_status,
|
|
||||||
pcd.amount, pcd.approval_number, pcd.card_number, pcd.card_type,
|
|
||||||
pcd.easypay_discount_amount, pcd.easypay_provider_code,
|
|
||||||
pcd.installment_plan_months, pcd.is_interest_free, pcd.issuer_code,
|
|
||||||
pcd.owner_type,
|
|
||||||
pep.amount AS easypay_amount,
|
|
||||||
pep.discount_amount AS easypay_discount_amount,
|
|
||||||
pep.easypay_provider_code AS easypay_provider
|
|
||||||
FROM
|
|
||||||
payment_detail pd
|
|
||||||
LEFT JOIN payment_bank_transfer_detail pbt ON pd.id = pbt.id
|
|
||||||
LEFT JOIN payment_card_detail pcd ON pd.id = pcd.id
|
|
||||||
LEFT JOIN payment_easypay_prepaid_detail pep ON pd.id = pep.id
|
|
||||||
WHERE
|
|
||||||
pd.payment_id = ?;
|
|
||||||
|
|
||||||
-- 취소 결제 정보 조회
|
|
||||||
SELECT
|
|
||||||
cp.id, cp.cancel_amount, cp.cancel_reason, cp.canceled_at,
|
|
||||||
cp.canceled_by, cp.card_discount_amount, cp.easypay_discount_amount,
|
|
||||||
cp.payment_id, cp.requested_at, cp.transfer_discount_amount
|
|
||||||
FROM
|
|
||||||
canceled_payment cp
|
|
||||||
WHERE
|
|
||||||
cp.payment_id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schedule
|
|
||||||
|
|
||||||
**날짜, 시간, 테마로 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
st.id AS store_id,
|
|
||||||
st.name AS store_name,
|
|
||||||
s.date,
|
|
||||||
s.time,
|
|
||||||
t.id AS theme_id,
|
|
||||||
t.name AS theme_name,
|
|
||||||
t.difficulty,
|
|
||||||
t.available_minutes,
|
|
||||||
s.status
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR t.id = ?)
|
|
||||||
JOIN store st ON st.id = s.store_id AND st.id = ?
|
|
||||||
WHERE
|
|
||||||
s.date = ?
|
|
||||||
```
|
|
||||||
|
|
||||||
**감사 정보 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 일정 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 작업자 조회(createdBy, updatedBy)
|
|
||||||
SELECT
|
|
||||||
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
|
|
||||||
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
|
|
||||||
a.updated_by
|
|
||||||
FROM
|
|
||||||
admin a
|
|
||||||
WHERE
|
|
||||||
a.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**일정 생성**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 날짜, 시간, 테마가 같은 일정 존재 여부 확인
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM schedule s
|
|
||||||
WHERE
|
|
||||||
s.store_id = ?
|
|
||||||
AND s.date = ?
|
|
||||||
AND s.theme_id = ?
|
|
||||||
AND s.time = ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 시간이 겹치는 같은 날의 일정이 있는지 확인
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
st.id AS store_id,
|
|
||||||
st.name AS store_name,
|
|
||||||
s.date,
|
|
||||||
s.time,
|
|
||||||
t.id AS theme_id,
|
|
||||||
t.name AS theme_name,
|
|
||||||
t.difficulty,
|
|
||||||
t.available_minutes,
|
|
||||||
s.status
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR s.theme_id = ?)
|
|
||||||
JOIN store st ON st.id = s.store_id AND st.id = ?
|
|
||||||
WHERE
|
|
||||||
s.date = ?
|
|
||||||
|
|
||||||
-- 일정 추가
|
|
||||||
INSERT INTO schedule (
|
|
||||||
created_at, created_by, date, status, store_id,
|
|
||||||
theme_id, time, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**일정 수정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**일정 삭제**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 삭제
|
|
||||||
DELETE FROM schedule
|
|
||||||
WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**상태 → HOLD 변경**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
|
|
||||||
s.theme_id, s.time, s.updated_at, s.updated_by
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
WHERE
|
|
||||||
s.id = ?;
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
schedule
|
|
||||||
SET
|
|
||||||
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
|
|
||||||
updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Store
|
|
||||||
|
|
||||||
**매장 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
|
|
||||||
-- 지역 정보 조회
|
|
||||||
SELECT
|
|
||||||
r.code, r.sido_code, r.sido_name, r.sigungu_code, r.sigungu_name
|
|
||||||
FROM
|
|
||||||
region r
|
|
||||||
WHERE
|
|
||||||
r.code = ?;
|
|
||||||
|
|
||||||
-- 감사 정보 조회(createdBy, updatedBy)
|
|
||||||
SELECT
|
|
||||||
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
|
|
||||||
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
|
|
||||||
a.updated_by
|
|
||||||
FROM
|
|
||||||
admin a
|
|
||||||
WHERE
|
|
||||||
a.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**매장 등록**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 이름 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.name = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 연락처 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.contact = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 주소 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.address = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 사업자번호 중복 확인
|
|
||||||
SELECT s.id FROM store s WHERE s.business_reg_num = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
INSERT INTO store (
|
|
||||||
address, business_reg_num, contact, created_at, created_by,
|
|
||||||
name, region_code, status, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**매장 수정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
store
|
|
||||||
SET
|
|
||||||
address = ?, business_reg_num = ?, contact = ?, name = ?,
|
|
||||||
region_code = ?, status = ?, updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**비활성화(status = DISABLE)**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
store
|
|
||||||
SET
|
|
||||||
address = ?, business_reg_num = ?, contact = ?, name = ?,
|
|
||||||
region_code = ?, status = ?, updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**모든 매장 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.status = 'ACTIVE'
|
|
||||||
AND (? IS NULL OR s.region_code LIKE ?);
|
|
||||||
```
|
|
||||||
|
|
||||||
**개별 매장 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
|
|
||||||
s.created_by, s.name, s.region_code, s.status, s.updated_at,
|
|
||||||
s.updated_by
|
|
||||||
FROM
|
|
||||||
store s
|
|
||||||
WHERE
|
|
||||||
s.id = ? AND s.status = 'ACTIVE';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Theme
|
|
||||||
|
|
||||||
**생성**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 이름으로 조회
|
|
||||||
SELECT
|
|
||||||
t.id
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.name = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
INSERT INTO theme (
|
|
||||||
available_minutes, created_at, created_by, description, difficulty,
|
|
||||||
expected_minutes_from, expected_minutes_to, is_active, max_participants,
|
|
||||||
min_participants, name, price, thumbnail_url, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Active인 모든 테마 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.is_active = TRUE;
|
|
||||||
```
|
|
||||||
|
|
||||||
**테마 목록 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t;
|
|
||||||
```
|
|
||||||
|
|
||||||
**감사 정보 포함 개별 테마 상세 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**개별 테마 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**삭제**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
|
|
||||||
-- 삭제
|
|
||||||
DELETE FROM theme WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**수정**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 조회
|
|
||||||
SELECT
|
|
||||||
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
|
|
||||||
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
|
|
||||||
t.is_active, t.max_participants, t.min_participants, t.name,
|
|
||||||
t.price, t.thumbnail_url, t.updated_at, t.updated_by
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
WHERE
|
|
||||||
t.id = ?;
|
|
||||||
|
|
||||||
-- 수정
|
|
||||||
UPDATE
|
|
||||||
theme
|
|
||||||
SET
|
|
||||||
available_minutes = ?, description = ?, difficulty = ?,
|
|
||||||
expected_minutes_from = ?, expected_minutes_to = ?, is_active = ?,
|
|
||||||
max_participants = ?, min_participants = ?, name = ?, price = ?,
|
|
||||||
thumbnail_url = ?, updated_at = ?, updated_by = ?
|
|
||||||
WHERE
|
|
||||||
id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**인기 테마 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price,
|
|
||||||
t.min_participants, t.max_participants,
|
|
||||||
t.available_minutes, t.expected_minutes_from, t.expected_minutes_to
|
|
||||||
FROM
|
|
||||||
theme t
|
|
||||||
JOIN (
|
|
||||||
SELECT
|
|
||||||
s.theme_id, count(*) as reservation_count
|
|
||||||
FROM
|
|
||||||
schedule s
|
|
||||||
JOIN
|
|
||||||
reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED'
|
|
||||||
WHERE
|
|
||||||
s.status = 'RESERVED'
|
|
||||||
AND (s.date BETWEEN :startFrom AND :endAt)
|
|
||||||
GROUP BY
|
|
||||||
s.theme_id
|
|
||||||
ORDER BY
|
|
||||||
reservation_count desc
|
|
||||||
LIMIT :count
|
|
||||||
) ranked_themes ON t.id = ranked_themes.theme_id
|
|
||||||
```
|
|
||||||
|
|
||||||
### User
|
|
||||||
|
|
||||||
**회원가입**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 이메일 중복 확인
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.email = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 연락처 중복 확인
|
|
||||||
SELECT
|
|
||||||
u.id
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.phone = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 추가
|
|
||||||
INSERT INTO users (
|
|
||||||
created_at, created_by, email, name, password, phone, region_code,
|
|
||||||
status, updated_at, updated_by, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 상태 변경 이력 추가
|
|
||||||
INSERT INTO user_status_history (
|
|
||||||
created_at, created_by, reason, status, updated_at, updated_by,
|
|
||||||
user_id, id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**연락처 정보 조회**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
|
|
||||||
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
|
|
||||||
FROM
|
|
||||||
users u
|
|
||||||
WHERE
|
|
||||||
u.id = ?;
|
|
||||||
```
|
|
||||||
@ -8,6 +8,10 @@ dependencies {
|
|||||||
// API docs
|
// API docs
|
||||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
|
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-cache")
|
||||||
|
implementation("com.github.ben-manes.caffeine:caffeine")
|
||||||
|
|
||||||
// DB
|
// DB
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
runtimeOnly("com.mysql:mysql-connector-j")
|
runtimeOnly("com.mysql:mysql-connector-j")
|
||||||
|
|||||||
@ -3,8 +3,14 @@ package com.sangdol.roomescape
|
|||||||
import org.springframework.boot.Banner
|
import org.springframework.boot.Banner
|
||||||
import org.springframework.boot.SpringApplication
|
import org.springframework.boot.SpringApplication
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
|
import org.springframework.cache.annotation.EnableCaching
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@EnableAsync
|
||||||
|
@EnableCaching
|
||||||
|
@EnableScheduling
|
||||||
@SpringBootApplication(
|
@SpringBootApplication(
|
||||||
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
|
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
@ -20,7 +20,7 @@ class AdminService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
|
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
|
||||||
log.info { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
|
log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
|
||||||
|
|
||||||
return adminRepository.findByAccount(account)
|
return adminRepository.findByAccount(account)
|
||||||
?.let {
|
?.let {
|
||||||
@ -28,14 +28,14 @@ class AdminService(
|
|||||||
it.toCredentials()
|
it.toCredentials()
|
||||||
}
|
}
|
||||||
?: run {
|
?: run {
|
||||||
log.info { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
|
log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
|
||||||
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
|
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findOperatorOrUnknown(id: Long): Auditor {
|
fun findOperatorOrUnknown(id: Long): Auditor {
|
||||||
log.info { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
|
log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
return adminRepository.findByIdOrNull(id)?.let { admin ->
|
return adminRepository.findByIdOrNull(id)?.let { admin ->
|
||||||
Auditor(admin.id, admin.name).also {
|
Auditor(admin.id, admin.name).also {
|
||||||
|
|||||||
@ -1,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,
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.sangdol.roomescape.admin.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
|
||||||
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
|
||||||
|
|
||||||
|
fun AdminEntity.toCredentials() = AdminLoginCredentials(
|
||||||
|
id = this.id,
|
||||||
|
password = this.password,
|
||||||
|
name = this.name,
|
||||||
|
type = this.type,
|
||||||
|
storeId = this.storeId,
|
||||||
|
permissionLevel = this.permissionLevel
|
||||||
|
)
|
||||||
@ -1,15 +1,20 @@
|
|||||||
package com.sangdol.roomescape.auth.business
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
import com.sangdol.roomescape.admin.business.AdminService
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
import com.sangdol.roomescape.auth.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
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -21,36 +26,37 @@ const val CLAIM_STORE_ID_KEY = "store_id"
|
|||||||
class AuthService(
|
class AuthService(
|
||||||
private val adminService: AdminService,
|
private val adminService: AdminService,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val loginHistoryService: LoginHistoryService,
|
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun login(
|
fun login(
|
||||||
request: LoginRequest,
|
request: LoginRequest,
|
||||||
context: LoginContext
|
context: LoginContext
|
||||||
): LoginSuccessResponse {
|
): LoginSuccessResponse {
|
||||||
log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
log.debug { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
|
||||||
val (credentials, extraClaims) = getCredentials(request)
|
val (credentials, extraClaims) = getCredentials(request)
|
||||||
|
|
||||||
|
val event = LoginHistoryEvent(
|
||||||
|
id = credentials.id,
|
||||||
|
type = request.principalType,
|
||||||
|
ipAddress = context.ipAddress,
|
||||||
|
userAgent = context.userAgent
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
verifyPasswordOrThrow(request, credentials)
|
verifyPasswordOrThrow(request, credentials)
|
||||||
|
|
||||||
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
|
||||||
|
|
||||||
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
|
eventPublisher.publishEvent(event.onSuccess())
|
||||||
|
|
||||||
return credentials.toResponse(accessToken).also {
|
return credentials.toResponse(accessToken).also {
|
||||||
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
|
eventPublisher.publishEvent(event.onFailure())
|
||||||
|
|
||||||
when (e) {
|
when (e) {
|
||||||
is AuthException -> {
|
is AuthException -> { throw e }
|
||||||
log.info { "[login] 로그인 실패: account = ${request.account}" }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
|
||||||
@ -65,7 +71,7 @@ class AuthService(
|
|||||||
credentials: LoginCredentials
|
credentials: LoginCredentials
|
||||||
) {
|
) {
|
||||||
if (credentials.password != request.password) {
|
if (credentials.password != request.password) {
|
||||||
log.info { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
|
||||||
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
throw AuthException(AuthErrorCode.LOGIN_FAILED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
||||||
|
import com.sangdol.roomescape.auth.mapper.toEntity
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LoginHistoryEventListener(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val loginHistoryRepository: LoginHistoryRepository,
|
||||||
|
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
|
||||||
|
private var batchSize: Int = 0
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener(classes = [LoginHistoryEvent::class])
|
||||||
|
fun onLoginCompleted(event: LoginHistoryEvent) {
|
||||||
|
log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
|
||||||
|
|
||||||
|
queue.add(event.toEntity(idGenerator.create())).also {
|
||||||
|
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.size >= batchSize) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
|
||||||
|
fun flushScheduled() {
|
||||||
|
log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun flushAll() {
|
||||||
|
log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flush() {
|
||||||
|
log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
val batch = mutableListOf<LoginHistoryEntity>()
|
||||||
|
repeat(batchSize) {
|
||||||
|
val entity: LoginHistoryEntity? = queue.poll()
|
||||||
|
|
||||||
|
if (entity != null) {
|
||||||
|
batch.add(entity)
|
||||||
|
} else {
|
||||||
|
return@repeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginHistoryRepository.saveAll(batch).also {
|
||||||
|
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package com.sangdol.roomescape.auth.business
|
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
|
||||||
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
|
|
||||||
import com.sangdol.roomescape.auth.web.LoginContext
|
|
||||||
import com.sangdol.roomescape.auth.web.PrincipalType
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Propagation
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class LoginHistoryService(
|
|
||||||
private val loginHistoryRepository: LoginHistoryRepository,
|
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
) {
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
fun createSuccessHistory(
|
|
||||||
principalId: Long,
|
|
||||||
principalType: PrincipalType,
|
|
||||||
context: LoginContext
|
|
||||||
) {
|
|
||||||
createHistory(principalId, principalType, success = true, context = context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
fun createFailureHistory(
|
|
||||||
principalId: Long,
|
|
||||||
principalType: PrincipalType,
|
|
||||||
context: LoginContext
|
|
||||||
) {
|
|
||||||
createHistory(principalId, principalType, success = false, context = context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createHistory(
|
|
||||||
principalId: Long,
|
|
||||||
principalType: PrincipalType,
|
|
||||||
success: Boolean,
|
|
||||||
context: LoginContext
|
|
||||||
) {
|
|
||||||
log.info { "[createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
LoginHistoryEntity(
|
|
||||||
id = idGenerator.create(),
|
|
||||||
principalId = principalId,
|
|
||||||
principalType = principalType,
|
|
||||||
success = success,
|
|
||||||
ipAddress = context.ipAddress,
|
|
||||||
userAgent = context.userAgent,
|
|
||||||
).also {
|
|
||||||
loginHistoryRepository.save(it)
|
|
||||||
log.info { "[createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
log.warn { "[createHistory] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business.domain
|
||||||
|
|
||||||
|
class LoginHistoryEvent(
|
||||||
|
val id: Long,
|
||||||
|
val type: PrincipalType,
|
||||||
|
var success: Boolean = true,
|
||||||
|
val ipAddress: String,
|
||||||
|
val userAgent: String
|
||||||
|
) {
|
||||||
|
fun onSuccess(): LoginHistoryEvent {
|
||||||
|
this.success = true
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFailure(): LoginHistoryEvent {
|
||||||
|
this.success = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.sangdol.roomescape.auth.business.domain
|
||||||
|
|
||||||
|
enum class PrincipalType {
|
||||||
|
USER, ADMIN
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.sangdol.roomescape.auth.docs
|
package com.sangdol.roomescape.auth.docs
|
||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.web.LoginRequest
|
import com.sangdol.roomescape.auth.dto.LoginRequest
|
||||||
import com.sangdol.roomescape.auth.web.LoginSuccessResponse
|
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
|
|||||||
@ -1,21 +1,12 @@
|
|||||||
package com.sangdol.roomescape.auth.web
|
package com.sangdol.roomescape.auth.dto
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import com.sangdol.roomescape.auth.business.domain.PrincipalType
|
||||||
|
|
||||||
enum class PrincipalType {
|
|
||||||
USER, ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LoginContext(
|
data class LoginContext(
|
||||||
val ipAddress: String,
|
val ipAddress: String,
|
||||||
val userAgent: String,
|
val userAgent: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun HttpServletRequest.toLoginContext() = LoginContext(
|
|
||||||
ipAddress = this.remoteAddr,
|
|
||||||
userAgent = this.getHeader("User-Agent")
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
val account: String,
|
val account: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
@ -50,7 +50,7 @@ class JwtUtils(
|
|||||||
val claims = extractAllClaims(token)
|
val claims = extractAllClaims(token)
|
||||||
|
|
||||||
return claims.subject ?: run {
|
return claims.subject ?: run {
|
||||||
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
log.debug { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
|
||||||
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
throw AuthException(AuthErrorCode.INVALID_TOKEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.sangdol.roomescape.auth.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
|
||||||
|
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
|
||||||
|
|
||||||
|
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
|
||||||
|
id = id,
|
||||||
|
principalId = this.id,
|
||||||
|
principalType = this.type,
|
||||||
|
success = this.success,
|
||||||
|
ipAddress = this.ipAddress,
|
||||||
|
userAgent = this.userAgent
|
||||||
|
)
|
||||||
@ -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")
|
||||||
|
)
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
package com.sangdol.roomescape.auth.web.support.resolver
|
package com.sangdol.roomescape.auth.web.support.resolver
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.auth.exception.AuthException
|
import com.sangdol.roomescape.auth.exception.AuthException
|
||||||
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
import com.sangdol.roomescape.auth.web.support.accessToken
|
import com.sangdol.roomescape.auth.web.support.accessToken
|
||||||
import com.sangdol.roomescape.user.business.UserService
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@ -21,7 +22,6 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
@Component
|
@Component
|
||||||
class UserContextResolver(
|
class UserContextResolver(
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
private val userService: UserService,
|
|
||||||
) : HandlerMethodArgumentResolver {
|
) : HandlerMethodArgumentResolver {
|
||||||
|
|
||||||
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||||
@ -38,9 +38,11 @@ class UserContextResolver(
|
|||||||
val token: String? = request.accessToken()
|
val token: String? = request.accessToken()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val id: Long = jwtUtils.extractSubject(token).toLong()
|
val id: Long = jwtUtils.extractSubject(token).also {
|
||||||
|
MdcPrincipalIdUtil.set(it)
|
||||||
|
}.toLong()
|
||||||
|
|
||||||
return userService.findContextById(id)
|
return CurrentUserContext(id = id)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
|
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
|
||||||
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import org.springframework.stereotype.Component
|
|||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Profile("local")
|
@Profile("!deploy & local")
|
||||||
class LocalDatabaseCleaner(
|
class LocalDatabaseCleaner(
|
||||||
private val jdbcTemplate: JdbcTemplate
|
private val jdbcTemplate: JdbcTemplate
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package com.sangdol.roomescape.common.types
|
package com.sangdol.roomescape.common.types
|
||||||
|
|
||||||
data class CurrentUserContext(
|
data class CurrentUserContext(
|
||||||
val id: Long,
|
val id: Long
|
||||||
val name: String,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.sangdol.roomescape.order.business
|
||||||
|
|
||||||
|
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.payment.business.PaymentService
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
|
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class OrderService(
|
||||||
|
private val reservationService: ReservationService,
|
||||||
|
private val scheduleService: ScheduleService,
|
||||||
|
private val paymentService: PaymentService,
|
||||||
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
|
private val orderValidator: OrderValidator,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
|
||||||
|
val paymentKey = paymentConfirmRequest.paymentKey
|
||||||
|
|
||||||
|
log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
|
try {
|
||||||
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
|
validateCanConfirm(reservationId)
|
||||||
|
reservationService.markInProgress(reservationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
|
||||||
|
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
|
||||||
|
|
||||||
|
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val errorCode: ErrorCode = if (e is RoomescapeException) {
|
||||||
|
e.errorCode
|
||||||
|
} else {
|
||||||
|
OrderErrorCode.ORDER_UNEXPECTED_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
throw OrderException(errorCode, e.message ?: errorCode.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateCanConfirm(reservationId: Long) {
|
||||||
|
log.debug { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
|
||||||
|
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
|
||||||
|
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
orderValidator.validateCanConfirm(reservation, schedule)
|
||||||
|
} catch (e: OrderException) {
|
||||||
|
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
|
||||||
|
throw OrderException(errorCode, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
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.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 {
|
||||||
|
fun validateCanConfirm(
|
||||||
|
reservation: ReservationStateResponse,
|
||||||
|
schedule: ScheduleStateResponse
|
||||||
|
) {
|
||||||
|
validateReservationStatus(reservation)
|
||||||
|
validateScheduleStatus(schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateReservationStatus(reservation: ReservationStateResponse) {
|
||||||
|
when (reservation.status) {
|
||||||
|
ReservationStatus.CONFIRMED -> {
|
||||||
|
throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED)
|
||||||
|
}
|
||||||
|
ReservationStatus.EXPIRED -> {
|
||||||
|
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||||
|
}
|
||||||
|
ReservationStatus.CANCELED -> {
|
||||||
|
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
|
||||||
|
if (schedule.status != ScheduleStatus.HOLD) {
|
||||||
|
log.debug { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
|
||||||
|
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
|
||||||
|
val nowDateTime = KoreaDateTime.now()
|
||||||
|
if (scheduleDateTime.isBefore(nowDateTime)) {
|
||||||
|
log.debug { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" }
|
||||||
|
throw OrderException(OrderErrorCode.PAST_SCHEDULE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.sangdol.roomescape.order.docs
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
|
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
|
||||||
|
interface OrderAPI {
|
||||||
|
|
||||||
|
@UserOnly
|
||||||
|
@Operation(summary = "결제 및 예약 완료 처리")
|
||||||
|
@ApiResponses(ApiResponse(responseCode = "200"))
|
||||||
|
fun confirm(
|
||||||
|
@PathVariable("reservationId") reservationId: Long,
|
||||||
|
@RequestBody request: PaymentConfirmRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>>
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.sangdol.roomescape.order.exception
|
||||||
|
|
||||||
|
import com.sangdol.common.types.exception.ErrorCode
|
||||||
|
import com.sangdol.common.types.web.HttpStatus
|
||||||
|
|
||||||
|
enum class OrderErrorCode(
|
||||||
|
override val httpStatus: HttpStatus,
|
||||||
|
override val errorCode: String,
|
||||||
|
override val message: String
|
||||||
|
) : ErrorCode {
|
||||||
|
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
|
||||||
|
ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
|
||||||
|
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
|
||||||
|
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
|
||||||
|
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
|
||||||
|
|
||||||
|
ORDER_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||||
|
;
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
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,
|
||||||
|
) : RoomescapeException(errorCode, message)
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.sangdol.roomescape.order.web
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
|
import com.sangdol.roomescape.order.business.OrderService
|
||||||
|
import com.sangdol.roomescape.order.docs.OrderAPI
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/orders")
|
||||||
|
class OrderController(
|
||||||
|
private val orderService: OrderService
|
||||||
|
) : OrderAPI {
|
||||||
|
|
||||||
|
@PostMapping("/{reservationId}/confirm")
|
||||||
|
override fun confirm(
|
||||||
|
@PathVariable("reservationId") reservationId: Long,
|
||||||
|
@RequestBody request: PaymentConfirmRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
|
orderService.confirm(reservationId, request)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,20 @@
|
|||||||
package com.sangdol.roomescape.payment.business
|
package com.sangdol.roomescape.payment.business
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
import com.sangdol.common.persistence.TransactionExecutionUtil
|
import com.sangdol.common.persistence.TransactionExecutionUtil
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
|
||||||
|
import com.sangdol.roomescape.payment.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.toEntity
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toEvent
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@ -17,73 +22,90 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class PaymentService(
|
class PaymentService(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
private val paymentClient: TosspayClient,
|
private val paymentClient: TosspayClient,
|
||||||
private val paymentRepository: PaymentRepository,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
private val paymentDetailRepository: PaymentDetailRepository,
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository,
|
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||||
private val paymentWriter: PaymentWriter,
|
|
||||||
private val transactionExecutionUtil: TransactionExecutionUtil,
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse {
|
fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
|
||||||
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm(
|
log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
|
||||||
paymentKey = request.paymentKey,
|
try {
|
||||||
orderId = request.orderId,
|
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
|
||||||
amount = request.amount,
|
eventPublisher.publishEvent(it.toEvent(reservationId))
|
||||||
)
|
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 -> {
|
||||||
|
log.warn(e) { "[requestConfirm] 예상치 못한 결제 실패: paymentKey=${request.paymentKey}" }
|
||||||
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
paymentWriter.cancel(
|
val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
|
||||||
userId = userId,
|
|
||||||
payment = payment,
|
clientCancelResponse.cancels.toEntity(
|
||||||
requestedAt = request.requestedAt,
|
id = idGenerator.create(),
|
||||||
cancelResponse = clientCancelResponse
|
paymentId = payment.id,
|
||||||
)
|
cancelRequestedAt = request.requestedAt,
|
||||||
|
canceledBy = userId
|
||||||
|
).also {
|
||||||
|
canceledPaymentRepository.save(it)
|
||||||
|
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
||||||
|
}
|
||||||
}.also {
|
}.also {
|
||||||
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findDetailByReservationId(reservationId: Long): PaymentWithDetailResponse? {
|
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
|
||||||
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
|
log.debug { "[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()
|
||||||
)
|
).also {
|
||||||
|
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
|
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
|
||||||
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||||
|
|
||||||
return paymentRepository.findByReservationId(reservationId)
|
return paymentRepository.findByReservationId(reservationId)
|
||||||
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
|
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
|
||||||
@ -94,7 +116,7 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
|
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
|
||||||
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||||
|
|
||||||
return paymentRepository.findByReservationId(reservationId)
|
return paymentRepository.findByReservationId(reservationId)
|
||||||
.also {
|
.also {
|
||||||
@ -107,7 +129,7 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
|
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
|
||||||
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
|
log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
|
||||||
|
|
||||||
return paymentDetailRepository.findByPaymentId(paymentId).also {
|
return paymentDetailRepository.findByPaymentId(paymentId).also {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
@ -119,7 +141,7 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
|
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
|
||||||
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
|
log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
|
||||||
|
|
||||||
return canceledPaymentRepository.findByPaymentId(paymentId).also {
|
return canceledPaymentRepository.findByPaymentId(paymentId).also {
|
||||||
if (it == null) {
|
if (it == null) {
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment.business
|
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.client.*
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class PaymentWriter(
|
|
||||||
private val paymentRepository: PaymentRepository,
|
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepository,
|
|
||||||
private val idGenerator: IDGenerator,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun createPayment(
|
|
||||||
reservationId: Long,
|
|
||||||
orderId: String,
|
|
||||||
paymentType: PaymentType,
|
|
||||||
paymentClientConfirmResponse: PaymentClientConfirmResponse
|
|
||||||
): PaymentEntity {
|
|
||||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
|
|
||||||
|
|
||||||
return paymentClientConfirmResponse.toEntity(
|
|
||||||
id = idGenerator.create(), reservationId, orderId, paymentType
|
|
||||||
).also {
|
|
||||||
paymentRepository.save(it)
|
|
||||||
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createDetail(
|
|
||||||
paymentResponse: PaymentClientConfirmResponse,
|
|
||||||
paymentId: Long,
|
|
||||||
): PaymentDetailEntity {
|
|
||||||
val method: PaymentMethod = paymentResponse.method
|
|
||||||
val id = idGenerator.create()
|
|
||||||
|
|
||||||
if (method == PaymentMethod.TRANSFER) {
|
|
||||||
return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId))
|
|
||||||
}
|
|
||||||
if (method == PaymentMethod.EASY_PAY && paymentResponse.card == null) {
|
|
||||||
return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId))
|
|
||||||
}
|
|
||||||
if (paymentResponse.card != null) {
|
|
||||||
return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId))
|
|
||||||
}
|
|
||||||
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(
|
|
||||||
userId: Long,
|
|
||||||
payment: PaymentEntity,
|
|
||||||
requestedAt: Instant,
|
|
||||||
cancelResponse: PaymentClientCancelResponse
|
|
||||||
): CanceledPaymentEntity {
|
|
||||||
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
|
|
||||||
|
|
||||||
paymentRepository.save(payment.apply { this.cancel() })
|
|
||||||
|
|
||||||
return cancelResponse.cancels.toEntity(
|
|
||||||
id = idGenerator.create(),
|
|
||||||
paymentId = payment.id,
|
|
||||||
cancelRequestedAt = requestedAt,
|
|
||||||
canceledBy = userId
|
|
||||||
).also {
|
|
||||||
canceledPaymentRepository.save(it)
|
|
||||||
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.common
|
package com.sangdol.roomescape.payment.business.domain
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator
|
import com.fasterxml.jackson.annotation.JsonCreator
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.sangdol.roomescape.payment.business.domain
|
||||||
|
|
||||||
|
abstract class PaymentDetail
|
||||||
|
|
||||||
|
class BankTransferPaymentDetail(
|
||||||
|
val bankCode: BankCode,
|
||||||
|
val settlementStatus: String,
|
||||||
|
): PaymentDetail()
|
||||||
|
|
||||||
|
class CardPaymentDetail(
|
||||||
|
val issuerCode: CardIssuerCode,
|
||||||
|
val number: String,
|
||||||
|
val amount: Int,
|
||||||
|
val cardType: CardType,
|
||||||
|
val ownerType: CardOwnerType,
|
||||||
|
val isInterestFree: Boolean,
|
||||||
|
val approveNo: String,
|
||||||
|
val installmentPlanMonths: Int
|
||||||
|
): PaymentDetail()
|
||||||
|
|
||||||
|
class EasypayCardPaymentDetail(
|
||||||
|
val issuerCode: CardIssuerCode,
|
||||||
|
val number: String,
|
||||||
|
val amount: Int,
|
||||||
|
val cardType: CardType,
|
||||||
|
val ownerType: CardOwnerType,
|
||||||
|
val isInterestFree: Boolean,
|
||||||
|
val approveNo: String,
|
||||||
|
val installmentPlanMonths: Int,
|
||||||
|
val easypayProvider: EasyPayCompanyCode,
|
||||||
|
val easypayDiscountAmount: Int,
|
||||||
|
): PaymentDetail()
|
||||||
|
|
||||||
|
class EasypayPrepaidPaymentDetail(
|
||||||
|
val provider: EasyPayCompanyCode,
|
||||||
|
val amount: Int,
|
||||||
|
val discountAmount: Int,
|
||||||
|
): PaymentDetail()
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.sangdol.roomescape.payment.business.event
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.PaymentDetail
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.PaymentType
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class PaymentEvent(
|
||||||
|
val reservationId: Long,
|
||||||
|
val paymentKey: String,
|
||||||
|
val orderId: String,
|
||||||
|
val type: PaymentType,
|
||||||
|
val status: PaymentStatus,
|
||||||
|
val totalAmount: Int,
|
||||||
|
val vat: Int,
|
||||||
|
val suppliedAmount: Int,
|
||||||
|
val method: PaymentMethod,
|
||||||
|
val requestedAt: Instant,
|
||||||
|
val approvedAt: Instant,
|
||||||
|
val detail: PaymentDetail
|
||||||
|
)
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.sangdol.roomescape.payment.business.event
|
||||||
|
|
||||||
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.mapper.toEntity
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class PaymentEventListener(
|
||||||
|
private val idGenerator: IDGenerator,
|
||||||
|
private val paymentRepository: PaymentRepository,
|
||||||
|
private val paymentDetailRepository: PaymentDetailRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
@Transactional
|
||||||
|
fun handlePaymentEvent(event: PaymentEvent) {
|
||||||
|
val reservationId = event.reservationId
|
||||||
|
|
||||||
|
log.debug { "[handlePaymentEvent] 결제 정보 저장 이벤트 수신: reservationId=${reservationId}, paymentKey=${event.paymentKey}" }
|
||||||
|
|
||||||
|
val paymentId = idGenerator.create()
|
||||||
|
val paymentEntity: PaymentEntity = event.toEntity(paymentId)
|
||||||
|
paymentRepository.save(paymentEntity)
|
||||||
|
|
||||||
|
val paymentDetailId = idGenerator.create()
|
||||||
|
val paymentDetailEntity: PaymentDetailEntity = event.toDetailEntity(id = paymentDetailId, paymentId = paymentId)
|
||||||
|
paymentDetailRepository.save(paymentDetailEntity)
|
||||||
|
|
||||||
|
log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 처리 완료: reservationId=${reservationId}, paymentId=${paymentId}, paymentDetailId=${paymentDetailId}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,29 +2,17 @@ package com.sangdol.roomescape.payment.docs
|
|||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.payment.web.PaymentCancelRequest
|
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
|
||||||
import com.sangdol.roomescape.payment.web.PaymentConfirmRequest
|
|
||||||
import com.sangdol.roomescape.payment.web.PaymentCreateResponse
|
|
||||||
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 {
|
||||||
|
|
||||||
@UserOnly
|
|
||||||
@Operation(summary = "결제 승인")
|
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
|
||||||
fun confirmPayment(
|
|
||||||
@RequestParam(required = true) reservationId: Long,
|
|
||||||
@Valid @RequestBody request: PaymentConfirmRequest
|
|
||||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>>
|
|
||||||
|
|
||||||
@Operation(summary = "결제 취소")
|
@Operation(summary = "결제 취소")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun cancelPayment(
|
fun cancelPayment(
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.sangdol.roomescape.payment.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.*
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.client.CancelDetailDeserializer
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
data class PaymentGatewayResponse(
|
||||||
|
val paymentKey: String,
|
||||||
|
val orderId: String,
|
||||||
|
val type: PaymentType,
|
||||||
|
val status: PaymentStatus,
|
||||||
|
val totalAmount: Int,
|
||||||
|
val vat: Int,
|
||||||
|
val suppliedAmount: Int,
|
||||||
|
val method: PaymentMethod,
|
||||||
|
val card: CardDetailResponse?,
|
||||||
|
val easyPay: EasyPayDetailResponse?,
|
||||||
|
val transfer: TransferDetailResponse?,
|
||||||
|
val requestedAt: OffsetDateTime,
|
||||||
|
val approvedAt: OffsetDateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaymentGatewayCancelResponse(
|
||||||
|
val status: PaymentStatus,
|
||||||
|
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
||||||
|
val cancels: CancelDetail,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardDetailResponse(
|
||||||
|
val issuerCode: CardIssuerCode,
|
||||||
|
val number: String,
|
||||||
|
val amount: Int,
|
||||||
|
val cardType: CardType,
|
||||||
|
val ownerType: CardOwnerType,
|
||||||
|
val isInterestFree: Boolean,
|
||||||
|
val approveNo: String,
|
||||||
|
val installmentPlanMonths: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EasyPayDetailResponse(
|
||||||
|
val provider: EasyPayCompanyCode,
|
||||||
|
val amount: Int,
|
||||||
|
val discountAmount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TransferDetailResponse(
|
||||||
|
val bankCode: BankCode,
|
||||||
|
val settlementStatus: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CancelDetail(
|
||||||
|
val cancelAmount: Int,
|
||||||
|
val cardDiscountAmount: Int,
|
||||||
|
val transferDiscountAmount: Int,
|
||||||
|
val easyPayDiscountAmount: Int,
|
||||||
|
val canceledAt: OffsetDateTime,
|
||||||
|
val cancelReason: String
|
||||||
|
)
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.sangdol.roomescape.payment.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class PaymentResponse(
|
||||||
|
val orderId: String,
|
||||||
|
val totalAmount: Int,
|
||||||
|
val method: String,
|
||||||
|
val status: PaymentStatus,
|
||||||
|
val requestedAt: Instant,
|
||||||
|
val approvedAt: Instant,
|
||||||
|
val detail: PaymentDetailResponse?,
|
||||||
|
val cancel: PaymentCancelDetailResponse?,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class PaymentDetailResponse {
|
||||||
|
data class CardDetailResponse(
|
||||||
|
val type: String = "CARD",
|
||||||
|
val issuerCode: String,
|
||||||
|
val cardType: String,
|
||||||
|
val ownerType: String,
|
||||||
|
val cardNumber: String,
|
||||||
|
val amount: Int,
|
||||||
|
val approvalNumber: String,
|
||||||
|
val installmentPlanMonths: Int,
|
||||||
|
val easypayProviderName: String?,
|
||||||
|
val easypayDiscountAmount: Int?,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
|
||||||
|
data class BankTransferDetailResponse(
|
||||||
|
val type: String = "BANK_TRANSFER",
|
||||||
|
val bankName: String,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
|
||||||
|
data class EasyPayPrepaidDetailResponse(
|
||||||
|
val type: String = "EASYPAY_PREPAID",
|
||||||
|
val providerName: String,
|
||||||
|
val amount: Int,
|
||||||
|
val discountAmount: Int,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PaymentCancelDetailResponse(
|
||||||
|
val cancellationRequestedAt: Instant,
|
||||||
|
val cancellationApprovedAt: Instant?,
|
||||||
|
val cancelReason: String,
|
||||||
|
val canceledBy: Long,
|
||||||
|
)
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.sangdol.roomescape.payment.dto
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class PaymentConfirmRequest(
|
||||||
|
val paymentKey: String,
|
||||||
|
val orderId: String,
|
||||||
|
val amount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaymentCancelRequest(
|
||||||
|
val reservationId: Long,
|
||||||
|
val cancelReason: String,
|
||||||
|
val requestedAt: Instant = Instant.now()
|
||||||
|
)
|
||||||
@ -6,3 +6,9 @@ class PaymentException(
|
|||||||
override val errorCode: PaymentErrorCode,
|
override val errorCode: PaymentErrorCode,
|
||||||
override val message: String = errorCode.message
|
override val message: String = errorCode.message
|
||||||
) : RoomescapeException(errorCode, message)
|
) : RoomescapeException(errorCode, message)
|
||||||
|
|
||||||
|
class ExternalPaymentException(
|
||||||
|
val httpStatusCode: Int,
|
||||||
|
val errorCode: String,
|
||||||
|
override val message: String
|
||||||
|
) : RuntimeException(message)
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.sangdol.roomescape.payment.exception
|
||||||
|
|
||||||
|
import com.sangdol.common.types.web.CommonErrorResponse
|
||||||
|
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
class PaymentExceptionHandler(
|
||||||
|
private val logMessageConverter: WebLogMessageConverter
|
||||||
|
) {
|
||||||
|
@ExceptionHandler(PaymentException::class)
|
||||||
|
fun handlePaymentException(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
e: PaymentException
|
||||||
|
): ResponseEntity<CommonErrorResponse> {
|
||||||
|
val errorCode = e.errorCode
|
||||||
|
val httpStatus = errorCode.httpStatus
|
||||||
|
val errorResponse = CommonErrorResponse(errorCode, e.message)
|
||||||
|
|
||||||
|
log.warn {
|
||||||
|
logMessageConverter.convertToErrorResponseMessage(
|
||||||
|
servletRequest = servletRequest,
|
||||||
|
httpStatus = httpStatus,
|
||||||
|
responseBody = errorResponse,
|
||||||
|
exception = if (e.message == errorCode.message) null else e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity
|
||||||
|
.status(httpStatus.value())
|
||||||
|
.body(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,67 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.client
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonParser
|
|
||||||
import com.fasterxml.jackson.databind.DeserializationContext
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
data class PaymentClientCancelResponse(
|
|
||||||
val status: PaymentStatus,
|
|
||||||
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
|
||||||
val cancels: CancelDetail,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CancelDetail(
|
|
||||||
val cancelAmount: Int,
|
|
||||||
val cardDiscountAmount: Int,
|
|
||||||
val transferDiscountAmount: Int,
|
|
||||||
val easyPayDiscountAmount: Int,
|
|
||||||
val canceledAt: OffsetDateTime,
|
|
||||||
val cancelReason: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun CancelDetail.toEntity(
|
|
||||||
id: Long,
|
|
||||||
paymentId: Long,
|
|
||||||
canceledBy: Long,
|
|
||||||
cancelRequestedAt: Instant
|
|
||||||
) = CanceledPaymentEntity(
|
|
||||||
id = id,
|
|
||||||
canceledAt = this.canceledAt.toInstant(),
|
|
||||||
requestedAt = cancelRequestedAt,
|
|
||||||
paymentId = paymentId,
|
|
||||||
canceledBy = canceledBy,
|
|
||||||
cancelReason = this.cancelReason,
|
|
||||||
cancelAmount = this.cancelAmount,
|
|
||||||
cardDiscountAmount = this.cardDiscountAmount,
|
|
||||||
transferDiscountAmount = this.transferDiscountAmount,
|
|
||||||
easypayDiscountAmount = this.easyPayDiscountAmount
|
|
||||||
)
|
|
||||||
|
|
||||||
class CancelDetailDeserializer : com.fasterxml.jackson.databind.JsonDeserializer<CancelDetail>() {
|
|
||||||
override fun deserialize(
|
|
||||||
p: JsonParser,
|
|
||||||
ctxt: DeserializationContext
|
|
||||||
): CancelDetail? {
|
|
||||||
val node: JsonNode = p.codec.readTree(p) ?: return null
|
|
||||||
|
|
||||||
val targetNode = when {
|
|
||||||
node.isArray && !node.isEmpty -> node[0]
|
|
||||||
node.isObject -> node
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return CancelDetail(
|
|
||||||
cancelAmount = targetNode.get("cancelAmount").asInt(),
|
|
||||||
cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(),
|
|
||||||
transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(),
|
|
||||||
easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(),
|
|
||||||
canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()),
|
|
||||||
cancelReason = targetNode.get("cancelReason").asText()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,9 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.client
|
package com.sangdol.roomescape.payment.infrastructure.client
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
|
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
@ -28,9 +31,9 @@ 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.debug { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
|
||||||
|
|
||||||
return confirmClient.request(paymentKey, orderId, amount)
|
return confirmClient.request(paymentKey, orderId, amount)
|
||||||
.also {
|
.also {
|
||||||
@ -42,9 +45,9 @@ 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.debug { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
|
||||||
|
|
||||||
return cancelClient.request(paymentKey, amount, cancelReason).also {
|
return cancelClient.request(paymentKey, amount, cancelReason).also {
|
||||||
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
|
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.client
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.*
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
data class PaymentClientConfirmResponse(
|
|
||||||
val paymentKey: String,
|
|
||||||
val status: PaymentStatus,
|
|
||||||
val totalAmount: Int,
|
|
||||||
val vat: Int,
|
|
||||||
val suppliedAmount: Int,
|
|
||||||
val method: PaymentMethod,
|
|
||||||
val card: CardDetail?,
|
|
||||||
val easyPay: EasyPayDetail?,
|
|
||||||
val transfer: TransferDetail?,
|
|
||||||
val requestedAt: OffsetDateTime,
|
|
||||||
val approvedAt: OffsetDateTime,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toEntity(
|
|
||||||
id: Long,
|
|
||||||
reservationId: Long,
|
|
||||||
orderId: String,
|
|
||||||
paymentType: PaymentType
|
|
||||||
) = PaymentEntity(
|
|
||||||
id = id,
|
|
||||||
reservationId = reservationId,
|
|
||||||
paymentKey = this.paymentKey,
|
|
||||||
orderId = orderId,
|
|
||||||
totalAmount = this.totalAmount,
|
|
||||||
requestedAt = this.requestedAt.toInstant(),
|
|
||||||
approvedAt = this.approvedAt.toInstant(),
|
|
||||||
type = paymentType,
|
|
||||||
method = this.method,
|
|
||||||
status = this.status,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CardDetail(
|
|
||||||
val issuerCode: CardIssuerCode,
|
|
||||||
val number: String,
|
|
||||||
val amount: Int,
|
|
||||||
val cardType: CardType,
|
|
||||||
val ownerType: CardOwnerType,
|
|
||||||
val isInterestFree: Boolean,
|
|
||||||
val approveNo: String,
|
|
||||||
val installmentPlanMonths: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
|
||||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
|
|
||||||
return PaymentCardDetailEntity(
|
|
||||||
id = id,
|
|
||||||
paymentId = paymentId,
|
|
||||||
suppliedAmount = this.suppliedAmount,
|
|
||||||
vat = this.vat,
|
|
||||||
issuerCode = cardDetail.issuerCode,
|
|
||||||
cardType = cardDetail.cardType,
|
|
||||||
ownerType = cardDetail.ownerType,
|
|
||||||
amount = cardDetail.amount,
|
|
||||||
cardNumber = cardDetail.number,
|
|
||||||
approvalNumber = cardDetail.approveNo,
|
|
||||||
installmentPlanMonths = cardDetail.installmentPlanMonths,
|
|
||||||
isInterestFree = cardDetail.isInterestFree,
|
|
||||||
easypayProviderCode = this.easyPay?.provider,
|
|
||||||
easypayDiscountAmount = this.easyPay?.discountAmount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class EasyPayDetail(
|
|
||||||
val provider: EasyPayCompanyCode,
|
|
||||||
val amount: Int,
|
|
||||||
val discountAmount: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
|
|
||||||
id: Long,
|
|
||||||
paymentId: Long
|
|
||||||
): PaymentEasypayPrepaidDetailEntity {
|
|
||||||
val easyPayDetail = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
|
|
||||||
return PaymentEasypayPrepaidDetailEntity(
|
|
||||||
id = id,
|
|
||||||
paymentId = paymentId,
|
|
||||||
suppliedAmount = this.suppliedAmount,
|
|
||||||
vat = this.vat,
|
|
||||||
easypayProviderCode = easyPayDetail.provider,
|
|
||||||
amount = easyPayDetail.amount,
|
|
||||||
discountAmount = easyPayDetail.discountAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class TransferDetail(
|
|
||||||
val bankCode: BankCode,
|
|
||||||
val settlementStatus: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentClientConfirmResponse.toTransferDetailEntity(
|
|
||||||
id: Long,
|
|
||||||
paymentId: Long
|
|
||||||
): PaymentBankTransferDetailEntity {
|
|
||||||
val transferDetail = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
|
||||||
|
|
||||||
return PaymentBankTransferDetailEntity(
|
|
||||||
id = id,
|
|
||||||
paymentId = paymentId,
|
|
||||||
suppliedAmount = this.suppliedAmount,
|
|
||||||
vat = this.vat,
|
|
||||||
bankCode = transferDetail.bankCode,
|
|
||||||
settlementStatus = transferDetail.settlementStatus
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.sangdol.roomescape.payment.infrastructure.client
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationContext
|
||||||
|
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.sangdol.roomescape.payment.dto.CancelDetail
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class CancelDetailDeserializer : JsonDeserializer<CancelDetail>() {
|
||||||
|
override fun deserialize(
|
||||||
|
p: JsonParser,
|
||||||
|
ctxt: DeserializationContext
|
||||||
|
): CancelDetail? {
|
||||||
|
val node: JsonNode = p.codec.readTree(p) ?: return null
|
||||||
|
|
||||||
|
val targetNode = when {
|
||||||
|
node.isArray && !node.isEmpty -> node[0]
|
||||||
|
node.isObject -> node
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return CancelDetail(
|
||||||
|
cancelAmount = targetNode.get("cancelAmount").asInt(),
|
||||||
|
cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(),
|
||||||
|
transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(),
|
||||||
|
easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(),
|
||||||
|
canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()),
|
||||||
|
cancelReason = targetNode.get("cancelReason").asText()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.persistence
|
package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||||
|
|
||||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.*
|
import com.sangdol.roomescape.payment.business.domain.BankCode
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.CardIssuerCode
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.CardOwnerType
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.CardType
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.EasyPayCompanyCode
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
package com.sangdol.roomescape.payment.infrastructure.persistence
|
package com.sangdol.roomescape.payment.infrastructure.persistence
|
||||||
|
|
||||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
import com.sangdol.common.persistence.PersistableBaseEntity
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentMethod
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
import com.sangdol.roomescape.payment.business.domain.PaymentType
|
||||||
import jakarta.persistence.Entity
|
import jakarta.persistence.Entity
|
||||||
import jakarta.persistence.EnumType
|
import jakarta.persistence.EnumType
|
||||||
import jakarta.persistence.Enumerated
|
import jakarta.persistence.Enumerated
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
package com.sangdol.roomescape.payment.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.*
|
||||||
|
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
||||||
|
import com.sangdol.roomescape.payment.dto.CancelDetail
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PaymentGatewayResponse.toEvent(reservationId: Long): PaymentEvent {
|
||||||
|
return PaymentEvent(
|
||||||
|
reservationId = reservationId,
|
||||||
|
paymentKey = this.paymentKey,
|
||||||
|
orderId = this.orderId,
|
||||||
|
type = this.type,
|
||||||
|
status = this.status,
|
||||||
|
totalAmount = this.totalAmount,
|
||||||
|
vat = this.vat,
|
||||||
|
suppliedAmount = this.suppliedAmount,
|
||||||
|
method = this.method,
|
||||||
|
requestedAt = this.requestedAt.toInstant(),
|
||||||
|
approvedAt = this.approvedAt.toInstant(),
|
||||||
|
detail = this.toDetail()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentGatewayResponse.toDetail(): PaymentDetail {
|
||||||
|
return when (this.method) {
|
||||||
|
PaymentMethod.TRANSFER -> this.toBankTransferDetail()
|
||||||
|
PaymentMethod.CARD -> this.toCardDetail()
|
||||||
|
PaymentMethod.EASY_PAY -> {
|
||||||
|
if (this.card != null) {
|
||||||
|
this.toEasypayCardDetail()
|
||||||
|
} else {
|
||||||
|
this.toEasypayPrepaidDetail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PaymentGatewayResponse.toBankTransferDetail(): BankTransferPaymentDetail {
|
||||||
|
val bankTransfer = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
|
return BankTransferPaymentDetail(
|
||||||
|
bankCode = bankTransfer.bankCode,
|
||||||
|
settlementStatus = bankTransfer.settlementStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PaymentGatewayResponse.toCardDetail(): CardPaymentDetail {
|
||||||
|
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
|
return CardPaymentDetail(
|
||||||
|
issuerCode = cardDetail.issuerCode,
|
||||||
|
number = cardDetail.number,
|
||||||
|
amount = cardDetail.amount,
|
||||||
|
cardType = cardDetail.cardType,
|
||||||
|
ownerType = cardDetail.ownerType,
|
||||||
|
isInterestFree = cardDetail.isInterestFree,
|
||||||
|
approveNo = cardDetail.approveNo,
|
||||||
|
installmentPlanMonths = cardDetail.installmentPlanMonths
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PaymentGatewayResponse.toEasypayCardDetail(): EasypayCardPaymentDetail {
|
||||||
|
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
|
return EasypayCardPaymentDetail(
|
||||||
|
issuerCode = cardDetail.issuerCode,
|
||||||
|
number = cardDetail.number,
|
||||||
|
amount = cardDetail.amount,
|
||||||
|
cardType = cardDetail.cardType,
|
||||||
|
ownerType = cardDetail.ownerType,
|
||||||
|
isInterestFree = cardDetail.isInterestFree,
|
||||||
|
approveNo = cardDetail.approveNo,
|
||||||
|
installmentPlanMonths = cardDetail.installmentPlanMonths,
|
||||||
|
easypayProvider = easypay.provider,
|
||||||
|
easypayDiscountAmount = easypay.discountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PaymentGatewayResponse.toEasypayPrepaidDetail(): EasypayPrepaidPaymentDetail {
|
||||||
|
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
|
return EasypayPrepaidPaymentDetail(
|
||||||
|
provider = easypay.provider,
|
||||||
|
amount = easypay.amount,
|
||||||
|
discountAmount = easypay.discountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
package com.sangdol.roomescape.payment.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.BankTransferPaymentDetail
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.CardPaymentDetail
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.EasypayCardPaymentDetail
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.EasypayPrepaidPaymentDetail
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
|
||||||
|
|
||||||
|
fun BankTransferPaymentDetail.toEntity(
|
||||||
|
id: Long,
|
||||||
|
paymentId: Long,
|
||||||
|
suppliedAmount: Int,
|
||||||
|
vat: Int
|
||||||
|
): PaymentDetailEntity {
|
||||||
|
return PaymentBankTransferDetailEntity(
|
||||||
|
id = id,
|
||||||
|
paymentId = paymentId,
|
||||||
|
suppliedAmount = suppliedAmount,
|
||||||
|
vat = vat,
|
||||||
|
bankCode = this.bankCode,
|
||||||
|
settlementStatus = this.settlementStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CardPaymentDetail.toEntity(
|
||||||
|
id: Long,
|
||||||
|
paymentId: Long,
|
||||||
|
suppliedAmount: Int,
|
||||||
|
vat: Int
|
||||||
|
): PaymentDetailEntity {
|
||||||
|
return PaymentCardDetailEntity(
|
||||||
|
id = id,
|
||||||
|
paymentId = paymentId,
|
||||||
|
suppliedAmount = suppliedAmount,
|
||||||
|
vat = vat,
|
||||||
|
issuerCode = issuerCode,
|
||||||
|
cardType = cardType,
|
||||||
|
ownerType = ownerType,
|
||||||
|
amount = amount,
|
||||||
|
cardNumber = this.number,
|
||||||
|
approvalNumber = this.approveNo,
|
||||||
|
installmentPlanMonths = installmentPlanMonths,
|
||||||
|
isInterestFree = isInterestFree,
|
||||||
|
easypayProviderCode = null,
|
||||||
|
easypayDiscountAmount = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun EasypayCardPaymentDetail.toEntity(
|
||||||
|
id: Long,
|
||||||
|
paymentId: Long,
|
||||||
|
suppliedAmount: Int,
|
||||||
|
vat: Int
|
||||||
|
): PaymentDetailEntity {
|
||||||
|
return PaymentCardDetailEntity(
|
||||||
|
id = id,
|
||||||
|
paymentId = paymentId,
|
||||||
|
suppliedAmount = suppliedAmount,
|
||||||
|
vat = vat,
|
||||||
|
issuerCode = issuerCode,
|
||||||
|
cardType = cardType,
|
||||||
|
ownerType = ownerType,
|
||||||
|
amount = amount,
|
||||||
|
cardNumber = this.number,
|
||||||
|
approvalNumber = this.approveNo,
|
||||||
|
installmentPlanMonths = installmentPlanMonths,
|
||||||
|
isInterestFree = isInterestFree,
|
||||||
|
easypayProviderCode = this.easypayProvider,
|
||||||
|
easypayDiscountAmount = this.easypayDiscountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun EasypayPrepaidPaymentDetail.toEntity(
|
||||||
|
id: Long,
|
||||||
|
paymentId: Long,
|
||||||
|
suppliedAmount: Int,
|
||||||
|
vat: Int
|
||||||
|
): PaymentDetailEntity {
|
||||||
|
return PaymentEasypayPrepaidDetailEntity(
|
||||||
|
id = id,
|
||||||
|
paymentId = paymentId,
|
||||||
|
suppliedAmount = suppliedAmount,
|
||||||
|
vat = vat,
|
||||||
|
easypayProviderCode = this.provider,
|
||||||
|
amount = this.amount,
|
||||||
|
discountAmount = this.discountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.sangdol.roomescape.payment.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.business.domain.*
|
||||||
|
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||||
|
|
||||||
|
fun PaymentEvent.toEntity(id: Long) = PaymentEntity(
|
||||||
|
id = id,
|
||||||
|
reservationId = this.reservationId,
|
||||||
|
paymentKey = this.paymentKey,
|
||||||
|
orderId = this.orderId,
|
||||||
|
totalAmount = this.totalAmount,
|
||||||
|
requestedAt = this.requestedAt,
|
||||||
|
approvedAt = this.approvedAt,
|
||||||
|
type = this.type,
|
||||||
|
method = this.method,
|
||||||
|
status = this.status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PaymentEvent.toDetailEntity(id: Long, paymentId: Long): PaymentDetailEntity {
|
||||||
|
val suppliedAmount = this.suppliedAmount
|
||||||
|
val vat = this.vat
|
||||||
|
|
||||||
|
return when (this.method) {
|
||||||
|
PaymentMethod.TRANSFER -> {
|
||||||
|
(this.detail as? BankTransferPaymentDetail)
|
||||||
|
?.toEntity(id, paymentId, suppliedAmount, vat)
|
||||||
|
?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentMethod.EASY_PAY -> {
|
||||||
|
when (this.detail) {
|
||||||
|
is EasypayCardPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) }
|
||||||
|
is EasypayPrepaidPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) }
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentMethod.CARD -> {
|
||||||
|
(this.detail as? CardPaymentDetail)
|
||||||
|
?.toEntity(id, paymentId, suppliedAmount, vat)
|
||||||
|
?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package com.sangdol.roomescape.payment.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentCancelDetailResponse
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentDetailResponse
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
|
import com.sangdol.roomescape.payment.exception.PaymentException
|
||||||
|
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
||||||
|
|
||||||
|
fun PaymentEntity.toResponse(
|
||||||
|
detail: PaymentDetailResponse?,
|
||||||
|
cancel: PaymentCancelDetailResponse?
|
||||||
|
): PaymentResponse {
|
||||||
|
return PaymentResponse(
|
||||||
|
orderId = this.orderId,
|
||||||
|
totalAmount = this.totalAmount,
|
||||||
|
method = this.method.koreanName,
|
||||||
|
status = this.status,
|
||||||
|
requestedAt = this.requestedAt,
|
||||||
|
approvedAt = this.approvedAt,
|
||||||
|
detail = detail,
|
||||||
|
cancel = cancel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentDetailEntity.toResponse(): PaymentDetailResponse {
|
||||||
|
return when (this) {
|
||||||
|
is PaymentCardDetailEntity -> this.toResponse()
|
||||||
|
is PaymentBankTransferDetailEntity -> this.toResponse()
|
||||||
|
is PaymentEasypayPrepaidDetailEntity -> this.toResponse()
|
||||||
|
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentCardDetailEntity.toResponse(): PaymentDetailResponse.CardDetailResponse {
|
||||||
|
return PaymentDetailResponse.CardDetailResponse(
|
||||||
|
issuerCode = this.issuerCode.koreanName,
|
||||||
|
cardType = this.cardType.koreanName,
|
||||||
|
ownerType = this.ownerType.koreanName,
|
||||||
|
cardNumber = this.cardNumber,
|
||||||
|
amount = this.amount,
|
||||||
|
approvalNumber = this.approvalNumber,
|
||||||
|
installmentPlanMonths = this.installmentPlanMonths,
|
||||||
|
easypayProviderName = this.easypayProviderCode?.koreanName,
|
||||||
|
easypayDiscountAmount = this.easypayDiscountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentBankTransferDetailEntity.toResponse(): PaymentDetailResponse.BankTransferDetailResponse {
|
||||||
|
return PaymentDetailResponse.BankTransferDetailResponse(
|
||||||
|
bankName = this.bankCode.koreanName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentEasypayPrepaidDetailEntity.toResponse(): PaymentDetailResponse.EasyPayPrepaidDetailResponse {
|
||||||
|
return PaymentDetailResponse.EasyPayPrepaidDetailResponse(
|
||||||
|
providerName = this.easypayProviderCode.koreanName,
|
||||||
|
amount = this.amount,
|
||||||
|
discountAmount = this.discountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CanceledPaymentEntity.toResponse(): PaymentCancelDetailResponse {
|
||||||
|
return PaymentCancelDetailResponse(
|
||||||
|
cancellationRequestedAt = this.requestedAt,
|
||||||
|
cancellationApprovedAt = this.canceledAt,
|
||||||
|
cancelReason = this.cancelReason,
|
||||||
|
canceledBy = this.canceledBy
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,26 +5,19 @@ 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 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.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/payments")
|
@RequestMapping("/payments")
|
||||||
class PaymentController(
|
class PaymentController(
|
||||||
private val paymentService: PaymentService
|
private val paymentService: PaymentService
|
||||||
) : PaymentAPI {
|
) : PaymentAPI {
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
override fun confirmPayment(
|
|
||||||
@RequestParam(required = true) reservationId: Long,
|
|
||||||
@Valid @RequestBody request: PaymentConfirmRequest
|
|
||||||
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>> {
|
|
||||||
val response = paymentService.confirm(reservationId, request)
|
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/cancel")
|
@PostMapping("/cancel")
|
||||||
override fun cancelPayment(
|
override fun cancelPayment(
|
||||||
@User user: CurrentUserContext,
|
@User user: CurrentUserContext,
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
package com.sangdol.roomescape.payment.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentStatus
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.common.PaymentType
|
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.*
|
|
||||||
import com.sangdol.roomescape.payment.web.PaymentDetailResponse.*
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
data class PaymentConfirmRequest(
|
|
||||||
val paymentKey: String,
|
|
||||||
val orderId: String,
|
|
||||||
val amount: Int,
|
|
||||||
val paymentType: PaymentType
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentCreateResponse(
|
|
||||||
val paymentId: Long,
|
|
||||||
val detailId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentCancelRequest(
|
|
||||||
val reservationId: Long,
|
|
||||||
val cancelReason: String,
|
|
||||||
val requestedAt: Instant = Instant.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentWithDetailResponse(
|
|
||||||
val orderId: String,
|
|
||||||
val totalAmount: Int,
|
|
||||||
val method: String,
|
|
||||||
val status: PaymentStatus,
|
|
||||||
val requestedAt: Instant,
|
|
||||||
val approvedAt: Instant,
|
|
||||||
val detail: PaymentDetailResponse?,
|
|
||||||
val cancel: PaymentCancelDetailResponse?,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PaymentEntity.toDetailResponse(
|
|
||||||
detail: PaymentDetailResponse?,
|
|
||||||
cancel: PaymentCancelDetailResponse?
|
|
||||||
): PaymentWithDetailResponse {
|
|
||||||
return PaymentWithDetailResponse(
|
|
||||||
orderId = this.orderId,
|
|
||||||
totalAmount = this.totalAmount,
|
|
||||||
method = this.method.koreanName,
|
|
||||||
status = this.status,
|
|
||||||
requestedAt = this.requestedAt,
|
|
||||||
approvedAt = this.approvedAt,
|
|
||||||
detail = detail,
|
|
||||||
cancel = cancel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class PaymentDetailResponse {
|
|
||||||
|
|
||||||
data class CardDetailResponse(
|
|
||||||
val type: String = "CARD",
|
|
||||||
val issuerCode: String,
|
|
||||||
val cardType: String,
|
|
||||||
val ownerType: String,
|
|
||||||
val cardNumber: String,
|
|
||||||
val amount: Int,
|
|
||||||
val approvalNumber: String,
|
|
||||||
val installmentPlanMonths: Int,
|
|
||||||
val easypayProviderName: String?,
|
|
||||||
val easypayDiscountAmount: Int?,
|
|
||||||
) : PaymentDetailResponse()
|
|
||||||
|
|
||||||
data class BankTransferDetailResponse(
|
|
||||||
val type: String = "BANK_TRANSFER",
|
|
||||||
val bankName: String,
|
|
||||||
) : PaymentDetailResponse()
|
|
||||||
|
|
||||||
data class EasyPayPrepaidDetailResponse(
|
|
||||||
val type: String = "EASYPAY_PREPAID",
|
|
||||||
val providerName: String,
|
|
||||||
val amount: Int,
|
|
||||||
val discountAmount: Int,
|
|
||||||
) : PaymentDetailResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentDetailEntity.toPaymentDetailResponse(): PaymentDetailResponse {
|
|
||||||
return when (this) {
|
|
||||||
is PaymentCardDetailEntity -> this.toCardDetailResponse()
|
|
||||||
is PaymentBankTransferDetailEntity -> this.toBankTransferDetailResponse()
|
|
||||||
is PaymentEasypayPrepaidDetailEntity -> this.toEasyPayPrepaidDetailResponse()
|
|
||||||
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentCardDetailEntity.toCardDetailResponse(): CardDetailResponse {
|
|
||||||
return CardDetailResponse(
|
|
||||||
issuerCode = this.issuerCode.koreanName,
|
|
||||||
cardType = this.cardType.koreanName,
|
|
||||||
ownerType = this.ownerType.koreanName,
|
|
||||||
cardNumber = this.cardNumber,
|
|
||||||
amount = this.amount,
|
|
||||||
approvalNumber = this.approvalNumber,
|
|
||||||
installmentPlanMonths = this.installmentPlanMonths,
|
|
||||||
easypayProviderName = this.easypayProviderCode?.koreanName,
|
|
||||||
easypayDiscountAmount = this.easypayDiscountAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentBankTransferDetailEntity.toBankTransferDetailResponse(): BankTransferDetailResponse {
|
|
||||||
return BankTransferDetailResponse(
|
|
||||||
bankName = this.bankCode.koreanName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayPrepaidDetailResponse {
|
|
||||||
return EasyPayPrepaidDetailResponse(
|
|
||||||
providerName = this.easypayProviderCode.koreanName,
|
|
||||||
amount = this.amount,
|
|
||||||
discountAmount = this.discountAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class PaymentCancelDetailResponse(
|
|
||||||
val cancellationRequestedAt: Instant,
|
|
||||||
val cancellationApprovedAt: Instant?,
|
|
||||||
val cancelReason: String,
|
|
||||||
val canceledBy: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun CanceledPaymentEntity.toCancelDetailResponse(): PaymentCancelDetailResponse {
|
|
||||||
return PaymentCancelDetailResponse(
|
|
||||||
cancellationRequestedAt = this.requestedAt,
|
|
||||||
cancellationApprovedAt = this.canceledAt,
|
|
||||||
cancelReason = this.cancelReason,
|
|
||||||
canceledBy = this.canceledBy
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,9 +1,9 @@
|
|||||||
package com.sangdol.roomescape.region.business
|
package com.sangdol.roomescape.region.business
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.region.dto.*
|
||||||
import com.sangdol.roomescape.region.exception.RegionErrorCode
|
import com.sangdol.roomescape.region.exception.RegionErrorCode
|
||||||
import com.sangdol.roomescape.region.exception.RegionException
|
import com.sangdol.roomescape.region.exception.RegionException
|
||||||
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
|
import com.sangdol.roomescape.region.infrastructure.persistence.RegionRepository
|
||||||
import com.sangdol.roomescape.region.web.*
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@ -17,7 +17,7 @@ class RegionService(
|
|||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun readAllSido(): SidoListResponse {
|
fun readAllSido(): SidoListResponse {
|
||||||
log.info { "[readAllSido] 모든 시/도 조회 시작" }
|
log.debug { "[readAllSido] 모든 시/도 조회 시작" }
|
||||||
val result: List<Pair<String, String>> = regionRepository.readAllSido()
|
val result: List<Pair<String, String>> = regionRepository.readAllSido()
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
if (result.isEmpty()) {
|
||||||
@ -32,7 +32,7 @@ class RegionService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findSigunguBySido(sidoCode: String): SigunguListResponse {
|
fun findSigunguBySido(sidoCode: String): SigunguListResponse {
|
||||||
log.info { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
|
log.debug { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
|
||||||
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
|
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
if (result.isEmpty()) {
|
||||||
@ -47,7 +47,7 @@ class RegionService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse {
|
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse {
|
||||||
log.info { "[findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
log.debug { "[findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||||
|
|
||||||
return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let {
|
return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let {
|
||||||
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
|
||||||
@ -60,7 +60,7 @@ class RegionService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findRegionInfo(regionCode: String): RegionInfoResponse {
|
fun findRegionInfo(regionCode: String): RegionInfoResponse {
|
||||||
log.info { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
|
log.debug { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
|
||||||
|
|
||||||
return regionRepository.findByCode(regionCode)?.let {
|
return regionRepository.findByCode(regionCode)?.let {
|
||||||
log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }
|
log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }
|
||||||
|
|||||||
@ -2,9 +2,9 @@ package com.sangdol.roomescape.region.docs
|
|||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.region.web.RegionCodeResponse
|
import com.sangdol.roomescape.region.dto.RegionCodeResponse
|
||||||
import com.sangdol.roomescape.region.web.SidoListResponse
|
import com.sangdol.roomescape.region.dto.SidoListResponse
|
||||||
import com.sangdol.roomescape.region.web.SigunguListResponse
|
import com.sangdol.roomescape.region.dto.SigunguListResponse
|
||||||
import io.swagger.v3.oas.annotations.Operation
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.sangdol.roomescape.region.web
|
package com.sangdol.roomescape.region.dto
|
||||||
|
|
||||||
data class SidoResponse(
|
data class SidoResponse(
|
||||||
val code: String,
|
val code: String,
|
||||||
@ -3,6 +3,9 @@ package com.sangdol.roomescape.region.web
|
|||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.region.business.RegionService
|
import com.sangdol.roomescape.region.business.RegionService
|
||||||
import com.sangdol.roomescape.region.docs.RegionAPI
|
import com.sangdol.roomescape.region.docs.RegionAPI
|
||||||
|
import com.sangdol.roomescape.region.dto.RegionCodeResponse
|
||||||
|
import com.sangdol.roomescape.region.dto.SidoListResponse
|
||||||
|
import com.sangdol.roomescape.region.dto.SigunguListResponse
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
|||||||
@ -3,17 +3,22 @@ package com.sangdol.roomescape.reservation.business
|
|||||||
import com.sangdol.common.persistence.IDGenerator
|
import com.sangdol.common.persistence.IDGenerator
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
import com.sangdol.roomescape.payment.business.PaymentService
|
||||||
import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.*
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationException
|
import com.sangdol.roomescape.reservation.exception.ReservationException
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.*
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.*
|
||||||
import com.sangdol.roomescape.reservation.web.*
|
import com.sangdol.roomescape.reservation.mapper.toAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.mapper.toEntity
|
||||||
|
import com.sangdol.roomescape.reservation.mapper.toOverviewResponse
|
||||||
|
import com.sangdol.roomescape.reservation.mapper.toStateResponse
|
||||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
|
|
||||||
import com.sangdol.roomescape.theme.business.ThemeService
|
import com.sangdol.roomescape.theme.business.ThemeService
|
||||||
import com.sangdol.roomescape.user.business.UserService
|
import com.sangdol.roomescape.user.business.UserService
|
||||||
import com.sangdol.roomescape.user.web.UserContactResponse
|
import com.sangdol.roomescape.user.dto.UserContactResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
@ -40,19 +45,26 @@ class ReservationService(
|
|||||||
user: CurrentUserContext,
|
user: CurrentUserContext,
|
||||||
request: PendingReservationCreateRequest
|
request: PendingReservationCreateRequest
|
||||||
): PendingReservationCreateResponse {
|
): PendingReservationCreateResponse {
|
||||||
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
|
log.debug { "[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}" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun confirmReservation(id: Long) {
|
fun confirmReservation(id: Long) {
|
||||||
log.info { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
|
log.debug { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
|
||||||
val reservation: ReservationEntity = findOrThrow(id)
|
val reservation: ReservationEntity = findOrThrow(id)
|
||||||
|
|
||||||
run {
|
run {
|
||||||
@ -69,7 +81,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
|
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
|
||||||
log.info { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
|
log.debug { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
|
||||||
|
|
||||||
val reservation: ReservationEntity = findOrThrow(reservationId)
|
val reservation: ReservationEntity = findOrThrow(reservationId)
|
||||||
|
|
||||||
@ -88,7 +100,7 @@ class ReservationService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
|
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
|
||||||
log.info { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
|
log.debug { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
|
||||||
|
|
||||||
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
|
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
|
||||||
userId = user.id,
|
userId = user.id,
|
||||||
@ -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.debug { "[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,8 +139,32 @@ class ReservationService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findStatusWithLock(id: Long): ReservationStateResponse {
|
||||||
|
log.debug { "[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.debug { "[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.debug { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
||||||
|
|
||||||
return reservationRepository.findByIdOrNull(id)
|
return reservationRepository.findByIdOrNull(id)
|
||||||
?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }
|
?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }
|
||||||
@ -149,14 +193,6 @@ class ReservationService(
|
|||||||
status = CanceledReservationStatus.COMPLETED
|
status = CanceledReservationStatus.COMPLETED
|
||||||
).also {
|
).also {
|
||||||
canceledReservationRepository.save(it)
|
canceledReservationRepository.save(it)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateCanCreate(request: PendingReservationCreateRequest) {
|
|
||||||
val schedule = scheduleService.findSummaryWithLock(request.scheduleId)
|
|
||||||
val theme = themeService.findInfoById(schedule.themeId)
|
|
||||||
|
|
||||||
reservationValidator.validateCanCreate(schedule, theme, request)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
package com.sangdol.roomescape.reservation.business
|
package com.sangdol.roomescape.reservation.business
|
||||||
|
|
||||||
|
import com.sangdol.common.utils.KoreaDateTime
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
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.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.LocalDateTime
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -16,22 +18,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.business.event
|
||||||
|
|
||||||
|
class ReservationConfirmEvent(
|
||||||
|
val reservationId: Long
|
||||||
|
)
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.business.event
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class ReservationEventListener(
|
||||||
|
private val reservationRepository: ReservationRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
@Transactional
|
||||||
|
fun handleReservationConfirmEvent(event: ReservationConfirmEvent) {
|
||||||
|
val reservationId = event.reservationId
|
||||||
|
|
||||||
|
log.debug { "[handleReservationConfirmEvent] 예약 확정 이벤트 수신: reservationId=${reservationId}" }
|
||||||
|
val modifiedRows = reservationRepository.confirmReservation(Instant.now(), reservationId)
|
||||||
|
|
||||||
|
if (modifiedRows == 0) {
|
||||||
|
log.warn { "[handleReservationConfirmEvent] 예상치 못한 예약 확정 실패 - 변경된 row 없음: reservationId=${reservationId}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 처리 완료" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleReposi
|
|||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@ -15,7 +14,6 @@ import java.util.concurrent.TimeUnit
|
|||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@EnableScheduling
|
|
||||||
class IncompletedReservationScheduler(
|
class IncompletedReservationScheduler(
|
||||||
private val scheduleRepository: ScheduleRepository,
|
private val scheduleRepository: ScheduleRepository,
|
||||||
private val reservationRepository: ReservationRepository
|
private val reservationRepository: ReservationRepository
|
||||||
@ -24,20 +22,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.debug { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
|
||||||
|
|
||||||
scheduleRepository.releaseExpiredHolds(Instant.now()).also {
|
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
|
||||||
log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" }
|
log.debug { "[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.debug { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
|
||||||
|
|
||||||
reservationRepository.expirePendingReservations(Instant.now()).also {
|
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
|
||||||
log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" }
|
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
|
||||||
|
}
|
||||||
|
|
||||||
|
reservationRepository.expirePendingReservations(Instant.now(), targets).also {
|
||||||
|
log.info { "[processExpiredReservation] ${it}개의 예약 및 일정 처리 완료" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,11 @@ import com.sangdol.common.types.web.CommonApiResponse
|
|||||||
import com.sangdol.roomescape.auth.web.support.User
|
import com.sangdol.roomescape.auth.web.support.User
|
||||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.reservation.web.*
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse
|
||||||
import io.swagger.v3.oas.annotations.Operation
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
@ -47,5 +51,5 @@ interface ReservationAPI {
|
|||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun findDetailById(
|
fun findDetailById(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>>
|
): ResponseEntity<CommonApiResponse<ReservationAdditionalResponse>>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
|
import com.sangdol.roomescape.user.dto.UserContactResponse
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class ReservationOverviewResponse(
|
||||||
|
val id: Long,
|
||||||
|
val storeName: String,
|
||||||
|
val themeName: String,
|
||||||
|
val date: LocalDate,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val endAt: LocalTime,
|
||||||
|
val status: ReservationStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReservationAdditionalResponse(
|
||||||
|
val id: Long,
|
||||||
|
val reserver: ReserverInfo,
|
||||||
|
val user: UserContactResponse,
|
||||||
|
val applicationDateTime: Instant,
|
||||||
|
val payment: PaymentResponse?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReserverInfo(
|
||||||
|
val name: String,
|
||||||
|
val contact: String,
|
||||||
|
val participantCount: Short,
|
||||||
|
val requirement: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReservationOverviewListResponse(
|
||||||
|
val reservations: List<ReservationOverviewResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReservationStateResponse(
|
||||||
|
val id: Long,
|
||||||
|
val scheduleId: Long,
|
||||||
|
val status: ReservationStatus,
|
||||||
|
val createdAt: Instant
|
||||||
|
)
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.dto
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty
|
||||||
|
|
||||||
|
data class ReservationCancelRequest(
|
||||||
|
val cancelReason: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PendingReservationCreateRequest(
|
||||||
|
val scheduleId: Long,
|
||||||
|
@NotEmpty
|
||||||
|
val reserverName: String,
|
||||||
|
@NotEmpty
|
||||||
|
val reserverContact: String,
|
||||||
|
val participantCount: Short,
|
||||||
|
val requirement: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PendingReservationCreateResponse(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
@ -12,6 +12,7 @@ enum class ReservationErrorCode(
|
|||||||
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
|
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
|
||||||
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
|
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
|
||||||
EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."),
|
EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."),
|
||||||
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.")
|
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요."),
|
||||||
|
PAST_SCHEDULE(HttpStatus.BAD_REQUEST, "R006", "지난 일정은 예약할 수 없어요.")
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,5 +31,5 @@ class ReservationEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class ReservationStatus {
|
enum class ReservationStatus {
|
||||||
PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED
|
PENDING, PAYMENT_IN_PROGRESS, CONFIRMED, CANCELED, EXPIRED;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package com.sangdol.roomescape.reservation.infrastructure.persistence
|
package com.sangdol.roomescape.reservation.infrastructure.persistence
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Lock
|
||||||
import org.springframework.data.jpa.repository.Modifying
|
import org.springframework.data.jpa.repository.Modifying
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
import org.springframework.data.repository.query.Param
|
||||||
@ -10,6 +12,25 @@ 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 +44,27 @@ 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
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
reservation r
|
||||||
|
JOIN
|
||||||
|
schedule s ON r.schedule_id = s.id AND s.status = 'HOLD'
|
||||||
|
SET
|
||||||
|
r.status = 'CONFIRMED',
|
||||||
|
r.updated_at = :now,
|
||||||
|
s.status = 'RESERVED',
|
||||||
|
s.hold_expired_at = NULL
|
||||||
|
WHERE
|
||||||
|
r.id = :id
|
||||||
|
AND r.status = 'PAYMENT_IN_PROGRESS'
|
||||||
|
""", nativeQuery = true
|
||||||
|
)
|
||||||
|
fun confirmReservation(@Param("now") now: Instant, @Param("id") id: Long): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.sangdol.roomescape.reservation.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.payment.dto.PaymentResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationOverviewResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReserverInfo
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||||
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
|
import com.sangdol.roomescape.user.dto.UserContactResponse
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
fun PendingReservationCreateRequest.toEntity(id: Long, userId: Long) = ReservationEntity(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
scheduleId = this.scheduleId,
|
||||||
|
reserverName = this.reserverName,
|
||||||
|
reserverContact = this.reserverContact,
|
||||||
|
participantCount = this.participantCount,
|
||||||
|
requirement = this.requirement,
|
||||||
|
status = ReservationStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReservationEntity.toOverviewResponse(
|
||||||
|
scheduleDate: LocalDate,
|
||||||
|
scheduleStartFrom: LocalTime,
|
||||||
|
scheduleEndAt: LocalTime,
|
||||||
|
storeName: String,
|
||||||
|
themeName: String
|
||||||
|
) = ReservationOverviewResponse(
|
||||||
|
id = this.id,
|
||||||
|
storeName = storeName,
|
||||||
|
themeName = themeName,
|
||||||
|
date = scheduleDate,
|
||||||
|
startFrom = scheduleStartFrom,
|
||||||
|
endAt = scheduleEndAt,
|
||||||
|
status = this.status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReservationEntity.toAdditionalResponse(
|
||||||
|
user: UserContactResponse,
|
||||||
|
payment: PaymentResponse?,
|
||||||
|
): ReservationAdditionalResponse {
|
||||||
|
return ReservationAdditionalResponse(
|
||||||
|
id = this.id,
|
||||||
|
reserver = this.toReserverInfo(),
|
||||||
|
user = user,
|
||||||
|
applicationDateTime = this.createdAt,
|
||||||
|
payment = payment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReservationEntity.toReserverInfo() = ReserverInfo(
|
||||||
|
name = this.reserverName,
|
||||||
|
contact = this.reserverContact,
|
||||||
|
participantCount = this.participantCount,
|
||||||
|
requirement = this.requirement
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReservationEntity.toStateResponse() = ReservationStateResponse(
|
||||||
|
id = this.id,
|
||||||
|
scheduleId = this.scheduleId,
|
||||||
|
status = this.status,
|
||||||
|
createdAt = this.createdAt
|
||||||
|
)
|
||||||
@ -5,6 +5,11 @@ import com.sangdol.roomescape.auth.web.support.User
|
|||||||
import com.sangdol.roomescape.common.types.CurrentUserContext
|
import com.sangdol.roomescape.common.types.CurrentUserContext
|
||||||
import com.sangdol.roomescape.reservation.business.ReservationService
|
import com.sangdol.roomescape.reservation.business.ReservationService
|
||||||
import com.sangdol.roomescape.reservation.docs.ReservationAPI
|
import com.sangdol.roomescape.reservation.docs.ReservationAPI
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationCancelRequest
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationAdditionalResponse
|
||||||
|
import com.sangdol.roomescape.reservation.dto.ReservationOverviewListResponse
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
@ -56,7 +61,7 @@ class ReservationController(
|
|||||||
@GetMapping("/{id}/detail")
|
@GetMapping("/{id}/detail")
|
||||||
override fun findDetailById(
|
override fun findDetailById(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<ReservationDetailResponse>> {
|
): ResponseEntity<CommonApiResponse<ReservationAdditionalResponse>> {
|
||||||
val response = reservationService.findDetailById(id)
|
val response = reservationService.findDetailById(id)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
package com.sangdol.roomescape.reservation.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.payment.web.PaymentWithDetailResponse
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
|
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
|
|
||||||
import com.sangdol.roomescape.user.web.UserContactResponse
|
|
||||||
import jakarta.validation.constraints.NotEmpty
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
data class PendingReservationCreateRequest(
|
|
||||||
val scheduleId: Long,
|
|
||||||
@NotEmpty
|
|
||||||
val reserverName: String,
|
|
||||||
@NotEmpty
|
|
||||||
val reserverContact: String,
|
|
||||||
val participantCount: Short,
|
|
||||||
val requirement: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun PendingReservationCreateRequest.toEntity(id: Long, userId: Long) = ReservationEntity(
|
|
||||||
id = id,
|
|
||||||
userId = userId,
|
|
||||||
scheduleId = this.scheduleId,
|
|
||||||
reserverName = this.reserverName,
|
|
||||||
reserverContact = this.reserverContact,
|
|
||||||
participantCount = this.participantCount,
|
|
||||||
requirement = this.requirement,
|
|
||||||
status = ReservationStatus.PENDING
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PendingReservationCreateResponse(
|
|
||||||
val id: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReservationOverviewResponse(
|
|
||||||
val id: Long,
|
|
||||||
val storeName: String,
|
|
||||||
val themeName: String,
|
|
||||||
val date: LocalDate,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val status: ReservationStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReservationEntity.toOverviewResponse(
|
|
||||||
schedule: ScheduleOverviewResponse
|
|
||||||
) = ReservationOverviewResponse(
|
|
||||||
id = this.id,
|
|
||||||
storeName = schedule.storeName,
|
|
||||||
themeName = schedule.themeName,
|
|
||||||
date = schedule.date,
|
|
||||||
startFrom = schedule.startFrom,
|
|
||||||
endAt = schedule.endAt,
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReservationOverviewListResponse(
|
|
||||||
val reservations: List<ReservationOverviewResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReserverInfo(
|
|
||||||
val name: String,
|
|
||||||
val contact: String,
|
|
||||||
val participantCount: Short,
|
|
||||||
val requirement: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReservationEntity.toReserverInfo() = ReserverInfo(
|
|
||||||
name = this.reserverName,
|
|
||||||
contact = this.reserverContact,
|
|
||||||
participantCount = this.participantCount,
|
|
||||||
requirement = this.requirement
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ReservationDetailResponse(
|
|
||||||
val id: Long,
|
|
||||||
val reserver: ReserverInfo,
|
|
||||||
val user: UserContactResponse,
|
|
||||||
val applicationDateTime: Instant,
|
|
||||||
val payment: PaymentWithDetailResponse?,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ReservationEntity.toReservationDetailRetrieveResponse(
|
|
||||||
user: UserContactResponse,
|
|
||||||
payment: PaymentWithDetailResponse?,
|
|
||||||
): ReservationDetailResponse {
|
|
||||||
return ReservationDetailResponse(
|
|
||||||
id = this.id,
|
|
||||||
reserver = this.toReserverInfo(),
|
|
||||||
user = user,
|
|
||||||
applicationDateTime = this.createdAt,
|
|
||||||
payment = payment,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ReservationCancelRequest(
|
|
||||||
val cancelReason: String
|
|
||||||
)
|
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
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.debug { "[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.debug { "[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.debug { "[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.debug { "[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.debug { "[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.debug { "[findOrThrow] 일정 조회 시작: id=$id" }
|
||||||
|
|
||||||
|
return scheduleRepository.findByIdOrNull(id)
|
||||||
|
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
|
||||||
|
?: run {
|
||||||
|
log.warn { "[findOrThrow] 일정 조회 실패. id=$id" }
|
||||||
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,21 @@
|
|||||||
package com.sangdol.roomescape.schedule.business
|
package com.sangdol.roomescape.schedule.business
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
|
||||||
import com.sangdol.common.utils.KoreaDate
|
import com.sangdol.common.utils.KoreaDate
|
||||||
import com.sangdol.common.utils.KoreaTime
|
import com.sangdol.common.utils.KoreaTime
|
||||||
import com.sangdol.roomescape.admin.business.AdminService
|
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
|
||||||
import com.sangdol.roomescape.common.types.Auditor
|
|
||||||
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeAndStoreResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
|
||||||
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
|
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
|
||||||
import com.sangdol.roomescape.schedule.exception.ScheduleException
|
import com.sangdol.roomescape.schedule.exception.ScheduleException
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.schedule.web.*
|
import com.sangdol.roomescape.schedule.mapper.toResponseWithTheme
|
||||||
|
import com.sangdol.roomescape.schedule.mapper.toResponseWithThemeAndStore
|
||||||
|
import com.sangdol.roomescape.schedule.mapper.toStateResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@ -24,34 +23,19 @@ 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.debug { "[getStoreScheduleByDate] 매장 일정 조회 시작: storeId=${storeId}, date=$date" }
|
||||||
|
|
||||||
val currentDate: LocalDate = KoreaDate.today()
|
val currentDate: LocalDate = KoreaDate.today()
|
||||||
val currentTime: LocalTime = KoreaTime.now()
|
val currentTime: LocalTime = KoreaTime.now()
|
||||||
|
|
||||||
if (date.isBefore(currentDate)) {
|
if (date.isBefore(currentDate)) {
|
||||||
log.warn { "[getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" }
|
|
||||||
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,128 +43,33 @@ 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.debug { "[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 변경 완료" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result == 0) {
|
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info { "[holdSchedule] 일정 Holding 완료: id=$id" }
|
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" }
|
log.debug { "[findDateTimeById] 일정 개요 조회 시작 : 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" }
|
|
||||||
|
|
||||||
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
|
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
|
||||||
?: run {
|
?: run {
|
||||||
@ -188,41 +77,38 @@ 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
|
||||||
fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) {
|
fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) {
|
||||||
log.info { "[reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
|
log.debug { "[reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
|
||||||
|
|
||||||
scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also {
|
scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also {
|
||||||
log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
|
log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
|
||||||
// Common (공통 메서드)
|
log.debug { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
|
||||||
// ========================================
|
|
||||||
private fun findOrThrow(id: Long): ScheduleEntity {
|
|
||||||
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
|
|
||||||
|
|
||||||
return scheduleRepository.findByIdOrNull(id)
|
return scheduleRepository.findByIdForUpdate(id)
|
||||||
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }
|
?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }
|
||||||
?: run {
|
?: run {
|
||||||
log.warn { "[updateSchedule] 일정 조회 실패. id=$id" }
|
log.warn { "[findForUpdateOrThrow] 일정 조회 실패. id=$id" }
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import com.sangdol.roomescape.schedule.exception.ScheduleException
|
|||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest
|
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@ -22,11 +22,20 @@ private val log: KLogger = KotlinLogging.logger {}
|
|||||||
class ScheduleValidator(
|
class ScheduleValidator(
|
||||||
private val scheduleRepository: ScheduleRepository
|
private val scheduleRepository: ScheduleRepository
|
||||||
) {
|
) {
|
||||||
|
fun validateCanHold(schedule: ScheduleEntity) {
|
||||||
|
if (schedule.status != ScheduleStatus.AVAILABLE) {
|
||||||
|
log.info { "[validateCanHold] HOLD 실패: id=${schedule.id}, status=${schedule.status}" }
|
||||||
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateNotInPast(schedule.date, schedule.time)
|
||||||
|
}
|
||||||
|
|
||||||
fun validateCanDelete(schedule: ScheduleEntity) {
|
fun validateCanDelete(schedule: ScheduleEntity) {
|
||||||
val status: ScheduleStatus = schedule.status
|
val status: ScheduleStatus = schedule.status
|
||||||
|
|
||||||
if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) {
|
if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) {
|
||||||
log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
|
log.info { "[validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,8 +59,8 @@ 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.debug {
|
||||||
"[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)
|
||||||
}
|
}
|
||||||
@ -62,8 +71,8 @@ class ScheduleValidator(
|
|||||||
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
|
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
|
||||||
|
|
||||||
if (inputDateTime.isBefore(now)) {
|
if (inputDateTime.isBefore(now)) {
|
||||||
log.info {
|
log.debug {
|
||||||
"[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.debug { "[validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" }
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import com.sangdol.roomescape.auth.web.support.AdminOnly
|
|||||||
import com.sangdol.roomescape.auth.web.support.Public
|
import com.sangdol.roomescape.auth.web.support.Public
|
||||||
import com.sangdol.roomescape.auth.web.support.UserOnly
|
import com.sangdol.roomescape.auth.web.support.UserOnly
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
import com.sangdol.roomescape.schedule.web.*
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
|
||||||
import io.swagger.v3.oas.annotations.Operation
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class AdminScheduleSummaryResponse(
|
||||||
|
val id: Long,
|
||||||
|
val themeName: String,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val endAt: LocalTime,
|
||||||
|
val status: ScheduleStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminScheduleSummaryListResponse(
|
||||||
|
val schedules: List<AdminScheduleSummaryResponse>
|
||||||
|
)
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class ScheduleCreateRequest(
|
||||||
|
val date: LocalDate,
|
||||||
|
val time: LocalTime,
|
||||||
|
val themeId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleCreateResponse(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleUpdateRequest(
|
||||||
|
val time: LocalTime? = null,
|
||||||
|
val status: ScheduleStatus? = null
|
||||||
|
) {
|
||||||
|
fun isAllParamsNull(): Boolean {
|
||||||
|
return time == null && status == null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.dto
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
data class ScheduleResponse(
|
||||||
|
val id: Long,
|
||||||
|
val date: LocalDate,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val endAt: LocalTime,
|
||||||
|
val status: ScheduleStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleStateResponse(
|
||||||
|
val date: LocalDate,
|
||||||
|
val startFrom: LocalTime,
|
||||||
|
val themeId: Long,
|
||||||
|
val status: ScheduleStatus,
|
||||||
|
val holdExpiredAt: Instant? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleThemeInfo(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleStoreInfo(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleWithThemeResponse(
|
||||||
|
val schedule: ScheduleResponse,
|
||||||
|
val theme: ScheduleThemeInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleWithThemeAndStoreResponse(
|
||||||
|
val schedule: ScheduleResponse,
|
||||||
|
val theme: ScheduleThemeInfo,
|
||||||
|
val store: ScheduleStoreInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleWithThemeListResponse(
|
||||||
|
val schedules: List<ScheduleWithThemeResponse>
|
||||||
|
)
|
||||||
@ -126,6 +126,26 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
|
expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
|
||||||
): Int
|
): Int
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id
|
||||||
|
FROM
|
||||||
|
schedule s
|
||||||
|
LEFT JOIN
|
||||||
|
reservation r
|
||||||
|
ON
|
||||||
|
r.schedule_id = s.id AND r.status IN ('PENDING', 'PAYMENT_IN_PROGRESS')
|
||||||
|
WHERE
|
||||||
|
s.status = 'HOLD'
|
||||||
|
AND s.hold_expired_at <= :now
|
||||||
|
AND r.id IS NULL
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
""", nativeQuery = true
|
||||||
|
)
|
||||||
|
fun findAllExpiredHeldSchedules(@Param("now") now: Instant): List<Long>
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
|
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
|
||||||
s.holdExpiredAt = NULL
|
s.holdExpiredAt = NULL
|
||||||
WHERE
|
WHERE
|
||||||
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD
|
s._id IN :scheduleIds
|
||||||
AND s.holdExpiredAt <= :now
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM ReservationEntity r
|
|
||||||
WHERE r.scheduleId = s._id
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun releaseExpiredHolds(@Param("now") now: Instant): Int
|
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryResponse
|
||||||
|
|
||||||
|
fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse(
|
||||||
|
id = this.id,
|
||||||
|
themeName = this.themeName,
|
||||||
|
startFrom = this.time,
|
||||||
|
endAt = this.getEndAt(),
|
||||||
|
status = this.status
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ScheduleOverview>.toAdminSummaryResponse() = AdminScheduleSummaryListResponse(
|
||||||
|
this.map { it.toAdminSummaryResponse() }
|
||||||
|
)
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.sangdol.roomescape.schedule.mapper
|
||||||
|
|
||||||
|
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
||||||
|
import com.sangdol.roomescape.schedule.dto.*
|
||||||
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
|
|
||||||
|
fun ScheduleEntity.toStateResponse() = ScheduleStateResponse(
|
||||||
|
date = this.date,
|
||||||
|
startFrom = this.time,
|
||||||
|
themeId = this.themeId,
|
||||||
|
status = this.status,
|
||||||
|
holdExpiredAt = this.holdExpiredAt
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ScheduleOverview.toResponseWithThemeAndStore() = ScheduleWithThemeAndStoreResponse(
|
||||||
|
schedule = ScheduleResponse(this.id, this.date, this.time, this.getEndAt(), this.status),
|
||||||
|
theme = ScheduleThemeInfo(this.themeId, this.themeName),
|
||||||
|
store = ScheduleStoreInfo(this.storeId, this.storeName),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ScheduleOverview.toResponseWithTheme() = ScheduleWithThemeResponse(
|
||||||
|
schedule = ScheduleResponse(this.id, this.date, this.time, this.getEndAt(), this.status),
|
||||||
|
theme = ScheduleThemeInfo(this.themeId, this.themeName),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun List<ScheduleOverview>.toResponseWithTheme() = ScheduleWithThemeListResponse(
|
||||||
|
this.map { it.toResponseWithTheme() }
|
||||||
|
)
|
||||||
@ -2,8 +2,13 @@ package com.sangdol.roomescape.schedule.web
|
|||||||
|
|
||||||
import com.sangdol.common.types.web.CommonApiResponse
|
import com.sangdol.common.types.web.CommonApiResponse
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
|
import com.sangdol.roomescape.schedule.business.AdminScheduleService
|
||||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI
|
import com.sangdol.roomescape.schedule.docs.AdminScheduleAPI
|
||||||
|
import com.sangdol.roomescape.schedule.dto.AdminScheduleSummaryListResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateRequest
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleCreateResponse
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleUpdateRequest
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.format.annotation.DateTimeFormat
|
import org.springframework.format.annotation.DateTimeFormat
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@ -13,7 +18,7 @@ import java.time.LocalDate
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin")
|
@RequestMapping("/admin")
|
||||||
class AdminScheduleController(
|
class AdminScheduleController(
|
||||||
private val scheduleService: ScheduleService,
|
private val adminScheduleService: AdminScheduleService,
|
||||||
) : AdminScheduleAPI {
|
) : AdminScheduleAPI {
|
||||||
@GetMapping("/stores/{storeId}/schedules")
|
@GetMapping("/stores/{storeId}/schedules")
|
||||||
override fun searchSchedules(
|
override fun searchSchedules(
|
||||||
@ -21,7 +26,7 @@ class AdminScheduleController(
|
|||||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
|
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate?,
|
||||||
@RequestParam(required = false) themeId: Long?,
|
@RequestParam(required = false) themeId: Long?,
|
||||||
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> {
|
): ResponseEntity<CommonApiResponse<AdminScheduleSummaryListResponse>> {
|
||||||
val response = scheduleService.searchSchedules(storeId, date, themeId)
|
val response = adminScheduleService.searchSchedules(storeId, date, themeId)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
@ -30,7 +35,7 @@ class AdminScheduleController(
|
|||||||
override fun findScheduleAudit(
|
override fun findScheduleAudit(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<AuditingInfo>> {
|
): ResponseEntity<CommonApiResponse<AuditingInfo>> {
|
||||||
val response = scheduleService.findScheduleAudit(id)
|
val response = adminScheduleService.findScheduleAudit(id)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
@ -40,7 +45,7 @@ class AdminScheduleController(
|
|||||||
@PathVariable("storeId") storeId: Long,
|
@PathVariable("storeId") storeId: Long,
|
||||||
@Valid @RequestBody request: ScheduleCreateRequest
|
@Valid @RequestBody request: ScheduleCreateRequest
|
||||||
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
|
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
|
||||||
val response = scheduleService.createSchedule(storeId, request)
|
val response = adminScheduleService.createSchedule(storeId, request)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
@ -50,7 +55,7 @@ class AdminScheduleController(
|
|||||||
@PathVariable("id") id: Long,
|
@PathVariable("id") id: Long,
|
||||||
@Valid @RequestBody request: ScheduleUpdateRequest
|
@Valid @RequestBody request: ScheduleUpdateRequest
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
scheduleService.updateSchedule(id, request)
|
adminScheduleService.updateSchedule(id, request)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(Unit))
|
return ResponseEntity.ok(CommonApiResponse(Unit))
|
||||||
}
|
}
|
||||||
@ -59,7 +64,7 @@ class AdminScheduleController(
|
|||||||
override fun deleteSchedule(
|
override fun deleteSchedule(
|
||||||
@PathVariable("id") id: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
scheduleService.deleteSchedule(id)
|
adminScheduleService.deleteSchedule(id)
|
||||||
|
|
||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
package com.sangdol.roomescape.schedule.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// All-Admin DTO (본사 + 매장)
|
|
||||||
// ========================================
|
|
||||||
data class AdminScheduleSummaryResponse(
|
|
||||||
val id: Long,
|
|
||||||
val themeName: String,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val status: ScheduleStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleOverview.toAdminSummaryResponse() = AdminScheduleSummaryResponse(
|
|
||||||
id = this.id,
|
|
||||||
themeName = this.themeName,
|
|
||||||
startFrom = this.time,
|
|
||||||
endAt = this.getEndAt(),
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdminScheduleSummaryListResponse(
|
|
||||||
val schedules: List<AdminScheduleSummaryResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
fun List<ScheduleOverview>.toAdminSummaryListResponse() = AdminScheduleSummaryListResponse(
|
|
||||||
this.map { it.toAdminSummaryResponse() }
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Store Admin DTO (매장)
|
|
||||||
// ========================================
|
|
||||||
data class ScheduleCreateRequest(
|
|
||||||
val date: LocalDate,
|
|
||||||
val time: LocalTime,
|
|
||||||
val themeId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleCreateResponse(
|
|
||||||
val id: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleUpdateRequest(
|
|
||||||
val time: LocalTime? = null,
|
|
||||||
val status: ScheduleStatus? = null
|
|
||||||
) {
|
|
||||||
fun isAllParamsNull(): Boolean {
|
|
||||||
return time == null && status == null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,6 +4,7 @@ import com.sangdol.common.types.web.CommonApiResponse
|
|||||||
import com.sangdol.roomescape.schedule.business.ScheduleService
|
import com.sangdol.roomescape.schedule.business.ScheduleService
|
||||||
import com.sangdol.roomescape.schedule.docs.PublicScheduleAPI
|
import com.sangdol.roomescape.schedule.docs.PublicScheduleAPI
|
||||||
import com.sangdol.roomescape.schedule.docs.UserScheduleAPI
|
import com.sangdol.roomescape.schedule.docs.UserScheduleAPI
|
||||||
|
import com.sangdol.roomescape.schedule.dto.ScheduleWithThemeListResponse
|
||||||
import org.springframework.format.annotation.DateTimeFormat
|
import org.springframework.format.annotation.DateTimeFormat
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
package com.sangdol.roomescape.schedule.web
|
|
||||||
|
|
||||||
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
|
||||||
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Public (인증 불필요)
|
|
||||||
// ========================================
|
|
||||||
data class ScheduleWithThemeResponse(
|
|
||||||
val id: Long,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String,
|
|
||||||
val themeDifficulty: Difficulty,
|
|
||||||
val status: ScheduleStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleOverview.toResponse() = ScheduleWithThemeResponse(
|
|
||||||
id = this.id,
|
|
||||||
startFrom = this.time,
|
|
||||||
endAt = this.getEndAt(),
|
|
||||||
themeId = this.themeId,
|
|
||||||
themeName = this.themeName,
|
|
||||||
themeDifficulty = this.themeDifficulty,
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleWithThemeListResponse(
|
|
||||||
val schedules: List<ScheduleWithThemeResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
fun List<ScheduleOverview>.toResponse() = ScheduleWithThemeListResponse(
|
|
||||||
this.map { it.toResponse() }
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Other-Service (API 없이 다른 서비스에서 호출)
|
|
||||||
// ========================================
|
|
||||||
data class ScheduleSummaryResponse(
|
|
||||||
val date: LocalDate,
|
|
||||||
val time: LocalTime,
|
|
||||||
val themeId: Long,
|
|
||||||
val status: ScheduleStatus
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse(
|
|
||||||
date = this.date,
|
|
||||||
time = this.time,
|
|
||||||
themeId = this.themeId,
|
|
||||||
status = this.status
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScheduleOverviewResponse(
|
|
||||||
val id: Long,
|
|
||||||
val storeId: Long,
|
|
||||||
val storeName: String,
|
|
||||||
val date: LocalDate,
|
|
||||||
val startFrom: LocalTime,
|
|
||||||
val endAt: LocalTime,
|
|
||||||
val themeId: Long,
|
|
||||||
val themeName: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ScheduleOverview.toOverviewResponse() = ScheduleOverviewResponse(
|
|
||||||
id = this.id,
|
|
||||||
storeId = this.storeId,
|
|
||||||
storeName = this.storeName,
|
|
||||||
date = this.date,
|
|
||||||
startFrom = this.time,
|
|
||||||
endAt = this.getEndAt(),
|
|
||||||
themeId = this.themeId,
|
|
||||||
themeName = this.themeName,
|
|
||||||
)
|
|
||||||
@ -4,12 +4,20 @@ import com.sangdol.common.persistence.IDGenerator
|
|||||||
import com.sangdol.roomescape.admin.business.AdminService
|
import com.sangdol.roomescape.admin.business.AdminService
|
||||||
import com.sangdol.roomescape.common.types.AuditingInfo
|
import com.sangdol.roomescape.common.types.AuditingInfo
|
||||||
import com.sangdol.roomescape.region.business.RegionService
|
import com.sangdol.roomescape.region.business.RegionService
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreDetailResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreNameListResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreInfoResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterRequest
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreRegisterResponse
|
||||||
|
import com.sangdol.roomescape.store.dto.StoreUpdateRequest
|
||||||
import com.sangdol.roomescape.store.exception.StoreErrorCode
|
import com.sangdol.roomescape.store.exception.StoreErrorCode
|
||||||
import com.sangdol.roomescape.store.exception.StoreException
|
import com.sangdol.roomescape.store.exception.StoreException
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreEntity
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
|
||||||
import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus
|
import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus
|
||||||
import com.sangdol.roomescape.store.web.*
|
import com.sangdol.roomescape.store.mapper.toDetailResponse
|
||||||
|
import com.sangdol.roomescape.store.mapper.toInfoResponse
|
||||||
|
import com.sangdol.roomescape.store.mapper.toSimpleListResponse
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@ -26,8 +34,8 @@ 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.debug { "[getDetail] 매장 상세 조회 시작: id=${id}" }
|
||||||
|
|
||||||
val store: StoreEntity = findOrThrow(id)
|
val store: StoreEntity = findOrThrow(id)
|
||||||
val region = regionService.findRegionInfo(store.regionCode)
|
val region = regionService.findRegionInfo(store.regionCode)
|
||||||
@ -39,7 +47,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun register(request: StoreRegisterRequest): StoreRegisterResponse {
|
fun register(request: StoreRegisterRequest): StoreRegisterResponse {
|
||||||
log.info { "[register] 매장 등록 시작: name=${request.name}" }
|
log.debug { "[register] 매장 등록 시작: name=${request.name}" }
|
||||||
|
|
||||||
storeValidator.validateCanRegister(request)
|
storeValidator.validateCanRegister(request)
|
||||||
|
|
||||||
@ -62,7 +70,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun update(id: Long, request: StoreUpdateRequest) {
|
fun update(id: Long, request: StoreUpdateRequest) {
|
||||||
log.info { "[update] 매장 수정 시작: id=${id}, request=${request}" }
|
log.debug { "[update] 매장 수정 시작: id=${id}, request=${request}" }
|
||||||
|
|
||||||
storeValidator.validateCanUpdate(request)
|
storeValidator.validateCanUpdate(request)
|
||||||
|
|
||||||
@ -75,7 +83,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun disableById(id: Long) {
|
fun disableById(id: Long) {
|
||||||
log.info { "[inactive] 매장 비활성화 시작: id=${id}" }
|
log.debug { "[inactive] 매장 비활성화 시작: id=${id}" }
|
||||||
|
|
||||||
findOrThrow(id).apply {
|
findOrThrow(id).apply {
|
||||||
this.disable()
|
this.disable()
|
||||||
@ -85,8 +93,8 @@ 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.debug { "[getAllActiveStores] 전체 매장 조회 시작" }
|
||||||
|
|
||||||
val regionCode: String? = when {
|
val regionCode: String? = when {
|
||||||
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
|
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
|
||||||
@ -100,7 +108,7 @@ class StoreService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findStoreInfo(id: Long): StoreInfoResponse {
|
fun findStoreInfo(id: Long): StoreInfoResponse {
|
||||||
log.info { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
|
log.debug { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
val store: StoreEntity = findOrThrow(id)
|
val store: StoreEntity = findOrThrow(id)
|
||||||
|
|
||||||
@ -109,7 +117,7 @@ class StoreService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getAuditInfo(store: StoreEntity): AuditingInfo {
|
private fun getAuditInfo(store: StoreEntity): AuditingInfo {
|
||||||
log.info { "[getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" }
|
log.debug { "[getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" }
|
||||||
val createdBy = adminService.findOperatorOrUnknown(store.createdBy)
|
val createdBy = adminService.findOperatorOrUnknown(store.createdBy)
|
||||||
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
|
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
|
||||||
|
|
||||||
@ -124,7 +132,7 @@ class StoreService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findOrThrow(id: Long): StoreEntity {
|
private fun findOrThrow(id: Long): StoreEntity {
|
||||||
log.info { "[findOrThrow] 매장 조회 시작: id=${id}" }
|
log.debug { "[findOrThrow] 매장 조회 시작: id=${id}" }
|
||||||
|
|
||||||
return storeRepository.findActiveStoreById(id)
|
return storeRepository.findActiveStoreById(id)
|
||||||
?.also {
|
?.also {
|
||||||
|
|||||||
@ -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
|
||||||
@ -31,21 +31,21 @@ class StoreValidator(
|
|||||||
|
|
||||||
private fun validateDuplicateNameExist(name: String) {
|
private fun validateDuplicateNameExist(name: String) {
|
||||||
if (storeRepository.existsByName(name)) {
|
if (storeRepository.existsByName(name)) {
|
||||||
log.info { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
|
log.debug { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
|
||||||
throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED)
|
throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateDuplicateContactExist(contact: String) {
|
private fun validateDuplicateContactExist(contact: String) {
|
||||||
if (storeRepository.existsByContact(contact)) {
|
if (storeRepository.existsByContact(contact)) {
|
||||||
log.info { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
|
log.debug { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
|
||||||
throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED)
|
throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateDuplicateAddressExist(address: String) {
|
private fun validateDuplicateAddressExist(address: String) {
|
||||||
if (storeRepository.existsByAddress(address)) {
|
if (storeRepository.existsByAddress(address)) {
|
||||||
log.info { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
|
log.debug { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
|
||||||
throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED)
|
throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = "특정 매장의 정보 조회")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user