[#41] 예약 스키마 재정의 #42

Merged
pricelees merged 41 commits from refactor/#41 into main 2025-09-09 00:43:39 +00:00
177 changed files with 3781 additions and 10102 deletions

View File

@ -23,6 +23,7 @@ import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21';
import HomePageV2 from './pages/v2/HomePageV2'; import HomePageV2 from './pages/v2/HomePageV2';
import LoginPageV2 from './pages/v2/LoginPageV2'; import LoginPageV2 from './pages/v2/LoginPageV2';
import SignupPageV2 from './pages/v2/SignupPageV2'; import SignupPageV2 from './pages/v2/SignupPageV2';
import ReservationFormPage from './pages/v2/ReservationFormPage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage'; import AdminSchedulePage from './pages/admin/AdminSchedulePage';
@ -72,6 +73,7 @@ function App() {
{/* V2.1 Reservation Flow */} {/* V2.1 Reservation Flow */}
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} /> <Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
<Route path="/v2/reservation/form" element={<ReservationFormPage />} />
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} /> <Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} /> <Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
</Routes> </Routes>

View File

@ -17,3 +17,9 @@ export interface SignupResponse {
id: string; id: string;
name: string; name: string;
} }
export interface MemberSummaryRetrieveResponse {
id: string;
name: string;
email: string;
}

View File

@ -0,0 +1,73 @@
export interface PaymentConfirmRequest {
paymentKey: string;
orderId: string;
amount: number;
paymentType: PaymentType;
}
export interface PaymentCancelRequest {
reservationId: string,
cancelReason: String
}
// V2 types
export const PaymentType = {
NORMAL: 'NORMAL',
BILLING: 'BILLING',
BRANDPAY: 'BRANDPAY'
} as const;
export type PaymentType =
| typeof PaymentType.NORMAL
| typeof PaymentType.BILLING
| typeof PaymentType.BRANDPAY;
export interface PaymentCreateResponseV2 {
paymentId: string;
detailId: string;
}
export interface PaymentRetrieveResponse {
orderId: string;
totalAmount: number;
method: string;
status: 'DONE' | 'CANCELED';
requestedAt: string;
approvedAt: string;
detail: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
cancellation?: CanceledPaymentDetailResponse;
}
export interface CardPaymentDetail {
type: 'CARD';
issuerCode: string;
cardType: 'CREDIT' | 'CHECK' | 'GIFT';
ownerType: 'PERSONAL' | 'CORPORATE';
cardNumber: string;
amount: number;
approvalNumber: string;
installmentPlanMonths: number;
isInterestFree: boolean;
easypayProviderName?: string;
easypayDiscountAmount?: number;
}
export interface BankTransferPaymentDetail {
type: 'BANK_TRANSFER';
bankName: string;
settlementStatus: string;
}
export interface EasyPayPrepaidPaymentDetail {
type: 'EASYPAY_PREPAID';
providerName: string;
amount: number;
discountAmount: number;
}
export interface CanceledPaymentDetailResponse {
cancellationRequestedAt: string; // ISO 8601 format
cancellationApprovedAt: string; // ISO 8601 format
cancelReason: string;
canceledBy: string;
}

View File

@ -0,0 +1,10 @@
import apiClient from "@_api/apiClient";
import type { PaymentCancelRequest, PaymentConfirmRequest, PaymentCreateResponseV2 } from "./PaymentTypes";
export const confirmPayment = async (reservationId: string, request: PaymentConfirmRequest): Promise<PaymentCreateResponseV2> => {
return await apiClient.post<PaymentCreateResponseV2>(`/payments?reservationId=${reservationId}`, request);
};
export const cancelPayment = async (request: PaymentCancelRequest): Promise<void> => {
return await apiClient.post(`/payments/cancel`, request);
};

View File

@ -85,10 +85,7 @@ export const confirmReservationPayment = async (id: string, data: ReservationPay
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true); return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
}; };
// POST /v2/reservations/{id}/cancel
export const cancelReservationV2 = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason }, true);
};
// GET /v2/reservations // GET /v2/reservations
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => { export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {

View File

@ -0,0 +1,23 @@
import apiClient from '../apiClient';
import type { PendingReservationCreateRequest, PendingReservationCreateResponse, ReservationDetailRetrieveResponse, ReservationSummaryRetrieveListResponse } from './reservationTypesV2';
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
};
export const confirmReservation = async (reservationId: string): Promise<void> => {
await apiClient.post(`/reservations/${reservationId}/confirm`, {});
};
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
};
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
}
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`);
}

View File

@ -1,4 +1,5 @@
import type { MemberRetrieveResponse } from '@_api/member/memberTypes'; import type { MemberRetrieveResponse, MemberSummaryRetrieveResponse } from '@_api/member/memberTypes';
import type { PaymentRetrieveResponse, PaymentType } from '@_api/payment/PaymentTypes';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes'; import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes'; import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
@ -77,18 +78,6 @@ export interface ReservationSearchQuery {
dateTo?: string; dateTo?: string;
} }
// V2 types
export const PaymentType = {
NORMAL: 'NORMAL',
BILLING: 'BILLING',
BRANDPAY: 'BRANDPAY'
} as const;
export type PaymentType =
| typeof PaymentType.NORMAL
| typeof PaymentType.BILLING
| typeof PaymentType.BRANDPAY;
export const PaymentStatus = { export const PaymentStatus = {
IN_PROGRESS: '결제 진행 중', IN_PROGRESS: '결제 진행 중',
DONE: '결제 완료', DONE: '결제 완료',
@ -123,7 +112,7 @@ export interface ReservationPaymentRequest {
paymentKey: string; paymentKey: string;
orderId: string; orderId: string;
amount: number; amount: number;
paymentType: PaymentType paymentType: PaymentType;
} }
export interface ReservationPaymentResponse { export interface ReservationPaymentResponse {
@ -133,75 +122,14 @@ export interface ReservationPaymentResponse {
paymentStatus: PaymentStatus; paymentStatus: PaymentStatus;
} }
export interface ReservationSummaryV2 {
id: string;
themeName: string;
date: string;
startAt: string;
status: string; // 'CONFIRMED', 'CANCELED_BY_USER', etc.
}
export interface ReservationSummaryListV2 {
reservations: ReservationSummaryV2[];
}
export interface ReservationDetailV2 { export interface ReservationDetailV2 {
id: string; id: string;
user: UserDetailV2; user: MemberSummaryRetrieveResponse;
themeName: string; themeName: string;
date: string; date: string;
startAt: string; startAt: string;
applicationDateTime: string; applicationDateTime: string;
payment: PaymentV2; payment: PaymentRetrieveResponse;
cancellation: CancellationV2 | null;
}
export interface UserDetailV2 {
id: string;
name: string;
email: string;
}
export interface PaymentV2 {
orderId: string;
totalAmount: number;
method: string;
status: 'DONE' | 'CANCELED';
requestedAt: string;
approvedAt: string;
detail: CardPaymentDetailV2 | BankTransferPaymentDetailV2 | EasyPayPrepaidPaymentDetailV2;
}
export interface CardPaymentDetailV2 {
type: 'CARD';
issuerCode: string;
cardType: 'CREDIT' | 'CHECK' | 'GIFT';
ownerType: 'PERSONAL' | 'CORPORATE';
cardNumber: string;
amount: number;
approvalNumber: string;
installmentPlanMonths: number;
isInterestFree: boolean;
easypayProviderName?: string;
easypayDiscountAmount?: number;
}
export interface BankTransferPaymentDetailV2 {
type: 'BANK_TRANSFER';
bankName: string;
settlementStatus: string;
}
export interface EasyPayPrepaidPaymentDetailV2 {
type: 'EASYPAY_PREPAID';
providerName: string;
amount: number;
discountAmount: number;
}
export interface CancellationV2 {
cancellationRequestedAt: string; // ISO 8601 format
cancellationApprovedAt: string; // ISO 8601 format
cancelReason: string;
canceledBy: string;
} }

View File

@ -0,0 +1,58 @@
import type { MemberSummaryRetrieveResponse } from "@_api/member/memberTypes";
import type { PaymentRetrieveResponse } from "@_api/payment/PaymentTypes";
export const ReservationStatusV2 = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
CANCELED: 'CANCELED',
FAILED: 'FAILED',
EXPIRED: 'EXPIRED'
} as const;
export type ReservationStatusV2 =
| typeof ReservationStatusV2.PENDING
| typeof ReservationStatusV2.CONFIRMED
| typeof ReservationStatusV2.CANCELED
| typeof ReservationStatusV2.FAILED
| typeof ReservationStatusV2.EXPIRED;
export interface PendingReservationCreateRequest {
scheduleId: string,
reserverName: string,
reserverContact: string,
participantCount: number,
requirement: string
}
export interface PendingReservationCreateResponse {
id: string
}
export interface ReservationSummaryRetrieveResponse {
id: string;
themeName: string;
date: string;
startAt: string;
status: ReservationStatusV2;
}
export interface ReservationSummaryRetrieveListResponse {
reservations: ReservationSummaryRetrieveResponse[];
}
export interface ReservationDetailRetrieveResponse {
id: string;
member: MemberSummaryRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}
export interface ReservationDetail {
id: string;
themeName: string;
date: string;
startAt: string;
member: MemberSummaryRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}

View File

@ -30,3 +30,7 @@ export const updateSchedule = async (id: string, request: ScheduleUpdateRequest)
export const deleteSchedule = async (id: string): Promise<void> => { export const deleteSchedule = async (id: string): Promise<void> => {
await apiClient.del(`/schedules/${id}`); await apiClient.del(`/schedules/${id}`);
}; };
export const holdSchedule = async (id: string): Promise<void> => {
await apiClient.patch(`/schedules/${id}/hold`, {});
};

View File

@ -1,6 +1,6 @@
export enum ScheduleStatus { export enum ScheduleStatus {
AVAILABLE = 'AVAILABLE', AVAILABLE = 'AVAILABLE',
PENDING = 'PENDING', HOLD = 'HOLD',
RESERVED = 'RESERVED', RESERVED = 'RESERVED',
BLOCKED = 'BLOCKED', BLOCKED = 'BLOCKED',
} }

View File

@ -417,4 +417,81 @@
} }
.modal-actions .confirm-button:hover { .modal-actions .confirm-button:hover {
background-color: #1B64DA; background-color: #1B64DA;
} }
/* Styles for ReservationFormPage */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: bold;
margin-bottom: 8px;
color: #333;
}
.form-group input[type="text"],
.form-group input[type="tel"],
.form-group input[type="number"],
.form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 16px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.participant-control {
display: flex;
align-items: center;
}
.participant-control input {
text-align: center;
border-left: none;
border-right: none;
width: 60px;
border-radius: 0;
}
.participant-control button {
width: 44px;
height: 44px;
border: 1px solid #ccc;
background-color: #f0f0f0;
font-size: 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.participant-control button:hover:not(:disabled) {
background-color: #e0e0e0;
}
.participant-control button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
color: #aaa;
}
.participant-control button:first-of-type {
border-radius: 8px 0 0 8px;
}
.participant-control button:last-of-type {
border-radius: 0 8px 8px 0;
}

View File

@ -11,7 +11,7 @@ const getScheduleStatusText = (status: ScheduleStatus): string => {
switch (status) { switch (status) {
case ScheduleStatus.AVAILABLE: case ScheduleStatus.AVAILABLE:
return '예약 가능'; return '예약 가능';
case ScheduleStatus.PENDING: case ScheduleStatus.HOLD:
return '예약 진행 중'; return '예약 진행 중';
case ScheduleStatus.RESERVED: case ScheduleStatus.RESERVED:
return '예약 완료'; return '예약 완료';

View File

@ -193,7 +193,7 @@ const AdminThemeEditPage: React.FC = () => {
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="price"> ()</label> <label className="form-label" htmlFor="price">1 ()</label>
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} /> <input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
</div> </div>
<div className="form-group"> <div className="form-group">

View File

@ -37,7 +37,7 @@ const AdminThemePage: React.FC = () => {
navigate('/admin/theme/edit/new'); navigate('/admin/theme/edit/new');
}; };
const handleManageClick = (themeId: number) => { const handleManageClick = (themeId: string) => {
navigate(`/admin/theme/edit/${themeId}`); navigate(`/admin/theme/edit/${themeId}`);
}; };
@ -54,7 +54,7 @@ const AdminThemePage: React.FC = () => {
<tr> <tr>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th>1 </th>
<th></th> <th></th>
<th></th> <th></th>
</tr> </tr>

View File

@ -1,10 +1,8 @@
import { cancelPayment } from '@_api/payment/paymentAPI';
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
import { cancelReservation, fetchDetailById, fetchSummaryByMember } from '@_api/reservation/reservationAPIV2';
import type { ReservationDetail, ReservationSummaryRetrieveResponse } from '@_api/reservation/reservationTypesV2';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import {
cancelReservationV2,
fetchMyReservationsV2,
fetchReservationDetailV2
} from '../../api/reservation/reservationAPI';
import type { PaymentV2, ReservationDetailV2, ReservationSummaryV2 } from '../../api/reservation/reservationTypes';
import '../../css/my-reservation-v2.css'; import '../../css/my-reservation-v2.css';
const formatDisplayDateTime = (dateTime: any): string => { const formatDisplayDateTime = (dateTime: any): string => {
@ -78,7 +76,7 @@ const formatCardDateTime = (dateStr: string, timeStr: string): string => {
// --- Cancellation View Component --- // --- Cancellation View Component ---
const CancellationView: React.FC<{ const CancellationView: React.FC<{
reservation: ReservationDetailV2; reservation: ReservationDetail;
onCancelSubmit: (reason: string) => void; onCancelSubmit: (reason: string) => void;
onBack: () => void; onBack: () => void;
isCancelling: boolean; isCancelling: boolean;
@ -119,13 +117,12 @@ const CancellationView: React.FC<{
}; };
// --- Reservation Detail View Component ---
const ReservationDetailView: React.FC<{ const ReservationDetailView: React.FC<{
reservation: ReservationDetailV2; reservation: ReservationDetail;
onGoToCancel: () => void; onGoToCancel: () => void;
}> = ({ reservation, onGoToCancel }) => { }> = ({ reservation, onGoToCancel }) => {
const renderPaymentDetails = (payment: PaymentV2) => { const renderPaymentDetails = (payment: PaymentRetrieveResponse) => {
const { detail } = payment; const { detail } = payment;
switch (detail.type) { switch (detail.type) {
@ -178,8 +175,8 @@ const ReservationDetailView: React.FC<{
<h3> </h3> <h3> </h3>
<p><strong> :</strong> {reservation.themeName}</p> <p><strong> :</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p> <p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
<p><strong> :</strong> {reservation.user.name}</p> <p><strong> :</strong> {reservation.member.name}</p>
<p><strong> :</strong> {reservation.user.email}</p> <p><strong> :</strong> {reservation.member.email}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
</div> </div>
<div className="modal-section-v2"> <div className="modal-section-v2">
@ -188,13 +185,13 @@ const ReservationDetailView: React.FC<{
{renderPaymentDetails(reservation.payment)} {renderPaymentDetails(reservation.payment)}
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
</div> </div>
{reservation.cancellation && ( {reservation.payment.cancellation && (
<div className="modal-section-v2 cancellation-section-v2"> <div className="modal-section-v2 cancellation-section-v2">
<h3> </h3> <h3> </h3>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationRequestedAt)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationRequestedAt)}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationApprovedAt)}</p> <p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationApprovedAt)}</p>
<p><strong> :</strong> {reservation.cancellation.cancelReason}</p> <p><strong> :</strong> {reservation.payment.cancellation.cancelReason}</p>
<p><strong> :</strong> {reservation.cancellation.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p> <p><strong> :</strong> {reservation.payment.cancellation.canceledBy == reservation.member.id ? '회원 본인' : '관리자'}</p>
</div> </div>
)} )}
{reservation.payment.status !== 'CANCELED' && ( {reservation.payment.status !== 'CANCELED' && (
@ -208,11 +205,11 @@ const ReservationDetailView: React.FC<{
// --- Main Page Component --- // --- Main Page Component ---
const MyReservationPageV2: React.FC = () => { const MyReservationPageV2: React.FC = () => {
const [reservations, setReservations] = useState<ReservationSummaryV2[]>([]); const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedReservation, setSelectedReservation] = useState<ReservationDetailV2 | null>(null); const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailLoading, setIsDetailLoading] = useState(false); const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null); const [detailError, setDetailError] = useState<string | null>(null);
@ -223,7 +220,7 @@ const MyReservationPageV2: React.FC = () => {
const loadReservations = async () => { const loadReservations = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const data = await fetchMyReservationsV2(); const data = await fetchSummaryByMember();
setReservations(data.reservations); setReservations(data.reservations);
setError(null); setError(null);
} catch (err) { } catch (err) {
@ -237,14 +234,21 @@ const MyReservationPageV2: React.FC = () => {
loadReservations(); loadReservations();
}, []); }, []);
const handleShowDetail = async (id: string) => { const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
try { try {
setIsDetailLoading(true); setIsDetailLoading(true);
setDetailError(null); setDetailError(null);
setModalView('detail'); setModalView('detail');
const detailData = await fetchReservationDetailV2(id); const detailData = await fetchDetailById(id);
console.log('상세 정보:', detailData); setSelectedReservation({
setSelectedReservation(detailData); id: detailData.id,
themeName: themeName,
date: date,
startAt: time,
member: detailData.member,
applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment
});
setIsModalOpen(true); setIsModalOpen(true);
} catch (err) { } catch (err) {
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.'); setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
@ -268,16 +272,18 @@ const MyReservationPageV2: React.FC = () => {
try { try {
setIsCancelling(true); setIsCancelling(true);
setDetailError(null); setDetailError(null);
await cancelReservationV2(selectedReservation.id, reason); await cancelReservation(selectedReservation.id, reason);
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.'); alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal(); handleCloseModal();
loadReservations(); // Refresh the list await loadReservations(); // Refresh the list
} catch (err) { } catch (err) {
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.'); setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
} finally { } finally {
setIsCancelling(false); setIsCancelling(true);
} }
}; };
console.log("reservations=", reservations);
return ( return (
<div className="my-reservation-container-v2"> <div className="my-reservation-container-v2">
@ -289,14 +295,13 @@ const MyReservationPageV2: React.FC = () => {
{!isLoading && !error && ( {!isLoading && !error && (
<div className="reservation-list-v2"> <div className="reservation-list-v2">
{reservations.map((res) => ( {reservations.map((res) => (
console.log(res), <div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toString().toLowerCase()}`}>
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toLowerCase()}`}>
<div className="summary-details-v2"> <div className="summary-details-v2">
<h3 className="summary-theme-name-v2">{res.themeName}</h3> <h3 className="summary-theme-name-v2">{res.themeName}</h3>
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p> <p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
</div> </div>
<button <button
onClick={() => handleShowDetail(res.id)} onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
disabled={isDetailLoading} disabled={isDetailLoading}
className="detail-button-v2" className="detail-button-v2"
> >

View File

@ -0,0 +1,121 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPIV2';
import '@_css/reservation-v2-1.css';
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
const ReservationFormPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { scheduleId, theme, date, time } = location.state || {};
const [reserverName, setReserverName] = useState('');
const [reserverContact, setReserverContact] = useState('');
const [participantCount, setParticipantCount] = useState(2);
const [requirement, setRequirement] = useState('');
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);
}
};
const handleCountChange = (delta: number) => {
setParticipantCount(prev => Math.max(theme.minParticipants, Math.min(theme.maxParticipants, prev + delta)));
};
const handlePayment = () => {
if (!reserverName || !reserverContact) {
alert('예약자명과 연락처를 입력해주세요.');
return;
}
const reservationData = {
scheduleId,
reserverName,
reserverContact,
participantCount,
requirement,
};
createPendingReservation(reservationData)
.then(res => {
navigate('/v2-1/reservation/payment', {
state: {
reservationId: res.id,
themeName: theme.name,
date: date,
startAt: time,
price: theme.price * participantCount,
}
});
})
.catch(handleError);
};
if (!scheduleId || !theme) {
// Handle case where state is not passed correctly
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
<p> . .</p>
<button onClick={() => navigate('/v2-1/reservation')} className="next-step-button"> </button>
</div>
);
}
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {theme.name}</p>
<p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {formatTime(time)}</p>
</div>
<div className="step-section">
<h3> </h3>
<div className="form-group">
<label htmlFor="reserverName"></label>
<input type="text" id="reserverName" value={reserverName} onChange={e => setReserverName(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="reserverContact"></label>
<input type="tel" id="reserverContact" value={reserverContact} onChange={e => setReserverContact(e.target.value)} placeholder="'-' 없이 입력"/>
</div>
<div className="form-group">
<label></label>
<div className="participant-control">
<input
type="number"
value={participantCount}
onChange={e => setParticipantCount(Math.max(theme.minParticipants, Math.min(theme.maxParticipants, Number(e.target.value))))}
min={theme.minParticipants}
max={theme.maxParticipants}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="requirement"></label>
<textarea id="requirement" value={requirement} onChange={e => setRequirement(e.target.value)} />
</div>
</div>
<div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button">
</button>
</div>
</div>
);
};
export default ReservationFormPage;

View File

@ -1,7 +1,6 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPI'; import { holdSchedule, findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI'; import { ScheduleStatus, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import { findThemesByIds } from '@_api/theme/themeAPI'; import { findThemesByIds } from '@_api/theme/themeAPI';
import { Difficulty } from '@_api/theme/themeTypes'; import { Difficulty } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
@ -68,7 +67,9 @@ const ReservationStep1PageV21: React.FC = () => {
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
findAvailableThemesByDate(dateStr) findAvailableThemesByDate(dateStr)
.then(res => { .then(res => {
console.log('Available themes response:', res);
const themeIds: string[] = res.themeIds; const themeIds: string[] = res.themeIds;
console.log('Available theme IDs:', themeIds);
if (themeIds.length > 0) { if (themeIds.length > 0) {
return findThemesByIds({ themeIds }); return findThemesByIds({ themeIds });
} else { } else {
@ -104,23 +105,26 @@ const ReservationStep1PageV21: React.FC = () => {
alert('날짜, 테마, 시간을 모두 선택해주세요.'); alert('날짜, 테마, 시간을 모두 선택해주세요.');
return; return;
} }
if (selectedSchedule.status !== 'AVAILABLE') { if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
alert('예약할 수 없는 시간입니다.'); alert('예약할 수 없는 시간입니다.');
return; return;
} }
setIsConfirmModalOpen(true); setIsConfirmModalOpen(true);
}; };
const handleConfirmPayment = () => { const handleConfirmReservation = () => {
if (!selectedDate || !selectedTheme || !selectedSchedule) return; if (!selectedSchedule) return;
const reservationData = { holdSchedule(selectedSchedule.id)
scheduleId: selectedSchedule.id, .then(() => {
}; navigate('/v2/reservation/form', {
state: {
createPendingReservation(reservationData) scheduleId: selectedSchedule.id,
.then((res) => { theme: selectedTheme,
navigate('/v2-1/reservation/payment', { state: { reservation: res } }); date: selectedDate.toLocaleDateString('en-CA'),
time: selectedSchedule.time,
}
});
}) })
.catch(handleError) .catch(handleError)
.finally(() => setIsConfirmModalOpen(false)); .finally(() => setIsConfirmModalOpen(false));
@ -197,8 +201,19 @@ const ReservationStep1PageV21: React.FC = () => {
setSelectedTheme(theme); setSelectedTheme(theme);
setIsThemeModalOpen(true); setIsThemeModalOpen(true);
}; };
const getStatusText = (status: ScheduleStatus) => {
switch (status) {
case ScheduleStatus.AVAILABLE:
return '예약가능';
case ScheduleStatus.HOLD:
return '예약 진행중';
default:
return '예약불가';
}
};
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE'; const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
@ -221,7 +236,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="theme-info"> <div className="theme-info">
<h4>{theme.name}</h4> <h4>{theme.name}</h4>
<div className="theme-meta"> <div className="theme-meta">
<p><strong>:</strong> {theme.price.toLocaleString()}</p> <p><strong>1 :</strong> {theme.price.toLocaleString()}</p>
<p><strong>:</strong> {getDifficultyText(theme.difficulty)}</p> <p><strong>:</strong> {getDifficultyText(theme.difficulty)}</p>
<p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p> <p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p> <p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
@ -240,11 +255,11 @@ const ReservationStep1PageV21: React.FC = () => {
{schedules.length > 0 ? schedules.map(schedule => ( {schedules.length > 0 ? schedules.map(schedule => (
<div <div
key={schedule.id} key={schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`} className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)} onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
> >
{schedule.time} {schedule.time}
<span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span> <span className="time-availability">{getStatusText(schedule.status)}</span>
</div> </div>
)) : <div className="no-times"> .</div>} )) : <div className="no-times"> .</div>}
</div> </div>
@ -252,7 +267,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="next-step-button-container"> <div className="next-step-button-container">
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}> <button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
</button> </button>
</div> </div>
@ -267,7 +282,7 @@ const ReservationStep1PageV21: React.FC = () => {
<p><strong>:</strong> {getDifficultyText(selectedTheme.difficulty)}</p> <p><strong>:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p> <p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p> <p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p>
<p><strong>:</strong> {selectedTheme.price.toLocaleString()}</p> <p><strong>1 :</strong> {selectedTheme.price.toLocaleString()}</p>
</div> </div>
<div className="modal-section"> <div className="modal-section">
<h3></h3> <h3></h3>
@ -286,11 +301,10 @@ const ReservationStep1PageV21: React.FC = () => {
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p> <p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
<p><strong>:</strong> {selectedTheme!!.name}</p> <p><strong>:</strong> {selectedTheme!!.name}</p>
<p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p> <p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p>
<p><strong>:</strong> {selectedTheme!!.price.toLocaleString()}</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>
<button className="confirm-button" onClick={handleConfirmPayment}></button> <button className="confirm-button" onClick={handleConfirmReservation}></button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,10 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
import { type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
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 { confirmReservationPayment } from '@_api/reservation/reservationAPI';
import { isLoginRequiredError } from '@_api/apiClient'; import { PaymentType } from '@_api/payment/PaymentTypes';
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
import '@_css/reservation-v2.css'; import '@_css/reservation-v2.css';
declare global { declare global {
@ -49,7 +51,7 @@ const ReservationStep2Page: React.FC = () => {
const paymentMethods = paymentWidget.renderPaymentMethods( const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method", "#payment-method",
{ value: 1000 }, // TODO: 테마별 가격 적용 { value: 1000 }, // TODO: 테마별 요금 적용
{ variantKey: "DEFAULT" } { variantKey: "DEFAULT" }
); );
paymentMethodsRef.current = paymentMethods; paymentMethodsRef.current = paymentMethods;

View File

@ -1,7 +1,8 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import { confirmReservationPayment } from '@_api/reservation/reservationAPI'; import { confirmPayment } from '@_api/payment/paymentAPI';
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes'; import { PaymentType, type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import '@_css/reservation-v2-1.css'; // Reuse the new CSS for consistency import { confirmReservation } from '@_api/reservation/reservationAPIV2';
import '@_css/reservation-v2-1.css';
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, formatTime } from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
@ -12,16 +13,14 @@ declare global {
} }
} }
// This component is designed to work with the state passed from ReservationStep1PageV21
const ReservationStep2PageV21: React.FC = () => { const ReservationStep2PageV21: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const paymentWidgetRef = useRef<any>(null); const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null); const paymentMethodsRef = useRef<any>(null);
// The reservation object now contains the price const { reservationId, themeName, date, startAt, price } = location.state || {};
const reservation: ReservationCreateResponse & { price: number } | undefined = location.state?.reservation;
console.log(reservation)
const handleError = (err: any) => { const handleError = (err: any) => {
if (isLoginRequiredError(err)) { if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.'); alert('로그인이 필요해요.');
@ -34,7 +33,7 @@ const ReservationStep2PageV21: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
if (!reservation) { if (!reservationId) {
alert('잘못된 접근입니다.'); alert('잘못된 접근입니다.');
navigate('/v2-1/reservation'); navigate('/v2-1/reservation');
return; return;
@ -52,15 +51,15 @@ const ReservationStep2PageV21: React.FC = () => {
const paymentMethods = paymentWidget.renderPaymentMethods( const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method", "#payment-method",
{ value: reservation.price }, // Use the price from the reservation object { value: price },
{ variantKey: "DEFAULT" } { variantKey: "DEFAULT" }
); );
paymentMethodsRef.current = paymentMethods; paymentMethodsRef.current = paymentMethods;
}; };
}, [reservation, navigate]); }, [reservationId, price, navigate]);
const handlePayment = () => { const handlePayment = () => {
if (!paymentWidgetRef.current || !reservation) { if (!paymentWidgetRef.current || !reservationId) {
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.'); alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
return; return;
} }
@ -70,24 +69,27 @@ const ReservationStep2PageV21: React.FC = () => {
paymentWidgetRef.current.requestPayment({ paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(), orderId: generateRandomString(),
orderName: `${reservation.themeName} 예약 결제`, orderName: `${themeName} 예약 결제`,
amount: reservation.price, amount: price,
}).then((data: any) => { }).then((data: any) => {
const paymentData: ReservationPaymentRequest = { const paymentData: PaymentConfirmRequest = {
paymentKey: data.paymentKey, paymentKey: data.paymentKey,
orderId: data.orderId, orderId: data.orderId,
amount: data.amount, amount: price, // Use the price from component state instead of widget response
paymentType: data.paymentType || PaymentType.NORMAL, paymentType: data.paymentType || PaymentType.NORMAL,
}; };
confirmReservationPayment(reservation.reservationId, paymentData)
.then((res) => { confirmPayment(reservationId, paymentData)
// Navigate to the new success page .then(() => {
return confirmReservation(reservationId);
})
.then(() => {
alert('결제가 완료되었어요!');
navigate('/v2-1/reservation/success', { navigate('/v2-1/reservation/success', {
state: { state: {
reservation: res, themeName,
themeName: reservation.themeName, date,
date: reservation.date, startAt,
startAt: reservation.startAt,
} }
}); });
}) })
@ -98,22 +100,19 @@ const ReservationStep2PageV21: React.FC = () => {
}); });
}; };
if (!reservation) { if (!reservationId) {
return null; return null;
} }
const date = formatDate(reservation.date)
const time = formatTime(reservation.startAt);
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
<h2 className="page-title"></h2> <h2 className="page-title"></h2>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {reservation.themeName}</p> <p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {date}</p> <p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {time}</p> <p><strong>:</strong> {formatTime(startAt)}</p>
<p><strong>:</strong> {reservation.price.toLocaleString()}</p> <p><strong>:</strong> {price.toLocaleString()}</p>
</div> </div>
<div className="step-section"> <div className="step-section">
<h3> </h3> <h3> </h3>
@ -122,7 +121,7 @@ const ReservationStep2PageV21: React.FC = () => {
</div> </div>
<div className="next-step-button-container"> <div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button"> <button onClick={handlePayment} className="next-step-button">
{reservation.price.toLocaleString()} {price.toLocaleString()}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,25 +1,16 @@
import React from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import type { ReservationPaymentResponse } from '@_api/reservation/reservationTypes';
import '@_css/reservation-v2-1.css'; // Reuse the new CSS import '@_css/reservation-v2-1.css'; // Reuse the new CSS
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
const ReservationSuccessPageV21: React.FC = () => { const ReservationSuccessPageV21: React.FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const { themeName, date, startAt } = (location.state as {
const { reservation, themeName, date, startAt } = (location.state as {
reservation: ReservationPaymentResponse;
themeName: string; themeName: string;
date: string; date: string;
startAt: string; startAt: string;
}) || {}; }) || {};
if (!reservation) {
React.useEffect(() => {
navigate('/v2-1/reservation'); // Redirect to the new reservation page on error
}, [navigate]);
return null;
}
const formattedDate = formatDate(date) const formattedDate = formatDate(date)
const formattedTime = formatTime(startAt); const formattedTime = formatTime(startAt);

View File

@ -42,7 +42,7 @@ abstract class PersistableBaseEntity(
@Transient @Transient
private var isNewEntity: Boolean = true private var isNewEntity: Boolean = true
): Persistable<Long> { ) : Persistable<Long> {
@PostLoad @PostLoad
@PostPersist @PostPersist
fun markNotNew() { fun markNotNew() {

View File

@ -1,11 +1,6 @@
package roomescape.common.entity package roomescape.common.entity
import jakarta.persistence.Column import jakarta.persistence.*
import jakarta.persistence.EntityListeners
import jakarta.persistence.Id
import jakarta.persistence.MappedSuperclass
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy import org.springframework.data.annotation.LastModifiedBy
@ -13,10 +8,32 @@ import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.domain.Persistable import org.springframework.data.domain.Persistable
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlin.jvm.Transient
@MappedSuperclass @MappedSuperclass
@EntityListeners(AuditingEntityListener::class) @EntityListeners(AuditingEntityListener::class)
abstract class AuditingBaseEntity( abstract class AuditingBaseEntity(
id: Long,
) : BaseEntityV2(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: LocalDateTime
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
@Column
@LastModifiedDate
lateinit var updatedAt: LocalDateTime
@Column
@LastModifiedBy
var updatedBy: Long = 0L
}
@MappedSuperclass
abstract class BaseEntityV2(
@Id @Id
@Column(name = "id") @Column(name = "id")
private val _id: Long, private val _id: Long,
@ -24,25 +41,6 @@ abstract class AuditingBaseEntity(
@Transient @Transient
private var isNewEntity: Boolean = true private var isNewEntity: Boolean = true
) : Persistable<Long> { ) : Persistable<Long> {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: LocalDateTime
protected set
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
protected set
@Column
@LastModifiedDate
lateinit var updatedAt: LocalDateTime
protected set
@Column
@LastModifiedBy
var updatedBy: Long = 0L
protected set
@PostLoad @PostLoad
@PrePersist @PrePersist

View File

@ -2,6 +2,7 @@ package roomescape.common.exception
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 org.slf4j.MDC import org.slf4j.MDC
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@ -14,6 +15,7 @@ import roomescape.common.dto.response.CommonErrorResponse
import roomescape.common.log.ApiLogMessageConverter import roomescape.common.log.ApiLogMessageConverter
import roomescape.common.log.ConvertResponseMessageRequest import roomescape.common.log.ConvertResponseMessageRequest
import roomescape.common.log.LogType import roomescape.common.log.LogType
import roomescape.common.log.getEndpoint
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -22,7 +24,10 @@ class ExceptionControllerAdvice(
private val messageConverter: ApiLogMessageConverter private val messageConverter: ApiLogMessageConverter
) { ) {
@ExceptionHandler(value = [RoomescapeException::class]) @ExceptionHandler(value = [RoomescapeException::class])
fun handleRoomException(e: RoomescapeException): ResponseEntity<CommonErrorResponse> { fun handleRoomException(
servletRequest: HttpServletRequest,
e: RoomescapeException
): ResponseEntity<CommonErrorResponse> {
val errorCode: ErrorCode = e.errorCode val errorCode: ErrorCode = e.errorCode
val httpStatus: HttpStatus = errorCode.httpStatus val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode) val errorResponse = CommonErrorResponse(errorCode)
@ -30,6 +35,7 @@ class ExceptionControllerAdvice(
val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE
logException( logException(
type = type, type = type,
servletRequest = servletRequest,
httpStatus = httpStatus.value(), httpStatus = httpStatus.value(),
errorResponse = errorResponse, errorResponse = errorResponse,
exception = e exception = e
@ -41,7 +47,10 @@ class ExceptionControllerAdvice(
} }
@ExceptionHandler(value = [MethodArgumentNotValidException::class, HttpMessageNotReadableException::class]) @ExceptionHandler(value = [MethodArgumentNotValidException::class, HttpMessageNotReadableException::class])
fun handleInvalidRequestValueException(e: Exception): ResponseEntity<CommonErrorResponse> { fun handleInvalidRequestValueException(
servletRequest: HttpServletRequest,
e: Exception
): ResponseEntity<CommonErrorResponse> {
val message: String = if (e is MethodArgumentNotValidException) { val message: String = if (e is MethodArgumentNotValidException) {
e.bindingResult.allErrors e.bindingResult.allErrors
.mapNotNull { it.defaultMessage } .mapNotNull { it.defaultMessage }
@ -57,6 +66,7 @@ class ExceptionControllerAdvice(
logException( logException(
type = LogType.APPLICATION_FAILURE, type = LogType.APPLICATION_FAILURE,
servletRequest = servletRequest,
httpStatus = httpStatus.value(), httpStatus = httpStatus.value(),
errorResponse = errorResponse, errorResponse = errorResponse,
exception = e exception = e
@ -68,7 +78,10 @@ class ExceptionControllerAdvice(
} }
@ExceptionHandler(value = [Exception::class]) @ExceptionHandler(value = [Exception::class])
fun handleException(e: Exception): ResponseEntity<CommonErrorResponse> { fun handleException(
servletRequest: HttpServletRequest,
e: Exception
): ResponseEntity<CommonErrorResponse> {
log.error(e) { "[ExceptionControllerAdvice] Unexpected exception occurred: ${e.message}" } log.error(e) { "[ExceptionControllerAdvice] Unexpected exception occurred: ${e.message}" }
val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR
@ -77,6 +90,7 @@ class ExceptionControllerAdvice(
logException( logException(
type = LogType.UNHANDLED_EXCEPTION, type = LogType.UNHANDLED_EXCEPTION,
servletRequest = servletRequest,
httpStatus = httpStatus.value(), httpStatus = httpStatus.value(),
errorResponse = errorResponse, errorResponse = errorResponse,
exception = e exception = e
@ -89,12 +103,14 @@ class ExceptionControllerAdvice(
private fun logException( private fun logException(
type: LogType, type: LogType,
servletRequest: HttpServletRequest,
httpStatus: Int, httpStatus: Int,
errorResponse: CommonErrorResponse, errorResponse: CommonErrorResponse,
exception: Exception exception: Exception
) { ) {
val commonRequest = ConvertResponseMessageRequest( val commonRequest = ConvertResponseMessageRequest(
type = type, type = type,
endpoint = servletRequest.getEndpoint(),
httpStatus = httpStatus, httpStatus = httpStatus,
startTime = MDC.get("startTime")?.toLongOrNull(), startTime = MDC.get("startTime")?.toLongOrNull(),
body = errorResponse, body = errorResponse,

View File

@ -45,6 +45,7 @@ class ApiLogMessageConverter(
fun convertToResponseMessage(request: ConvertResponseMessageRequest): String { fun convertToResponseMessage(request: ConvertResponseMessageRequest): String {
val payload: MutableMap<String, Any> = mutableMapOf() val payload: MutableMap<String, Any> = mutableMapOf()
payload["type"] = request.type payload["type"] = request.type
payload["endpoint"] = request.endpoint
payload["status_code"] = request.httpStatus payload["status_code"] = request.httpStatus
MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull() MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull()
@ -75,8 +76,11 @@ class ApiLogMessageConverter(
data class ConvertResponseMessageRequest( data class ConvertResponseMessageRequest(
val type: LogType, val type: LogType,
val endpoint: String,
val httpStatus: Int = 200, val httpStatus: Int = 200,
val startTime: Long? = null, val startTime: Long? = null,
val body: Any? = null, val body: Any? = null,
val exception: Exception? = null val exception: Exception? = null
) )
fun HttpServletRequest.getEndpoint(): String = "${this.method} ${this.requestURI}"

View File

@ -33,28 +33,31 @@ class ControllerLoggingAspect(
val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis() val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis()
val controllerPayload: Map<String, Any> = parsePayload(joinPoint) val controllerPayload: Map<String, Any> = parsePayload(joinPoint)
val servletRequest: HttpServletRequest = servletRequest()
log.info { log.info {
messageConverter.convertToControllerInvokedMessage(servletRequest(), controllerPayload) messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
} }
try { try {
return joinPoint.proceed() return joinPoint.proceed()
.also { logSuccess(startTime, it) } .also { logSuccess(servletRequest.getEndpoint(), startTime, it) }
} catch (e: Exception) { } catch (e: Exception) {
throw e throw e
} }
} }
private fun logSuccess(startTime: Long, result: Any) { private fun logSuccess(endpoint: String, startTime: Long, result: Any) {
val responseEntity = result as ResponseEntity<*> val responseEntity = result as ResponseEntity<*>
var convertResponseMessageRequest = ConvertResponseMessageRequest( var convertResponseMessageRequest = ConvertResponseMessageRequest(
type = LogType.CONTROLLER_SUCCESS, type = LogType.CONTROLLER_SUCCESS,
endpoint = endpoint,
httpStatus = responseEntity.statusCode.value(), httpStatus = responseEntity.statusCode.value(),
startTime = startTime, startTime = startTime,
) )
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
convertResponseMessageRequest = convertResponseMessageRequest.copy( convertResponseMessageRequest = convertResponseMessageRequest.copy(
body = responseEntity.body body = responseEntity.body
) )
} }

View File

@ -5,7 +5,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.member.implement.MemberFinder import roomescape.member.implement.MemberFinder
import roomescape.member.implement.MemberWriter import roomescape.member.implement.MemberWriter
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.* import roomescape.member.web.*
@ -26,11 +25,14 @@ class MemberService(
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(memberId: Long): MemberEntity { fun findSummaryById(id: Long): MemberSummaryRetrieveResponse {
log.debug { "[MemberService.findById] 시작" } log.debug { "[MemberService.findSummaryById] 시작" }
return memberFinder.findById(memberId) return memberFinder.findById(id)
.also { log.info { "[MemberService.findById] 완료. memberId=${memberId}, email=${it.email}" } } .toSummaryRetrieveResponse()
.also {
log.info { "[MemberService.findSummaryById] 완료. memberId=${id}, email=${it.email}" }
}
} }
@Transactional @Transactional

View File

@ -22,7 +22,7 @@ class MemberEntity(
@Column(name = "role", nullable = false, length = 20) @Column(name = "role", nullable = false, length = 20)
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var role: Role var role: Role
): BaseEntity() { ) : BaseEntity() {
override fun getId(): Long? = _id override fun getId(): Long? = _id
fun isAdmin(): Boolean = role == Role.ADMIN fun isAdmin(): Boolean = role == Role.ADMIN

View File

@ -2,6 +2,7 @@ package roomescape.member.web
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveResponse( fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveResponse(
id = id!!, id = id!!,
@ -39,3 +40,17 @@ fun MemberEntity.toSignupResponse(): SignupResponse = SignupResponse(
id = this.id!!, id = this.id!!,
name = this.name name = this.name
) )
data class MemberSummaryRetrieveResponse(
val id: Long,
val name: String,
val email: String,
val role: Role
)
fun MemberEntity.toSummaryRetrieveResponse() = MemberSummaryRetrieveResponse(
id = this.id!!,
name = this.name,
email = this.email,
role = this.role
)

View File

@ -1,99 +1,101 @@
package roomescape.payment.business package roomescape.payment.business
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
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.payment.implement.PaymentFinder import roomescape.common.util.TransactionExecutionUtil
import roomescape.payment.implement.PaymentWriter import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import roomescape.payment.infrastructure.client.PaymentClientCancelResponse
import roomescape.payment.infrastructure.persistence.PaymentEntity import roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.infrastructure.client.TosspayClient
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.infrastructure.persistence.*
import roomescape.payment.web.PaymentCreateResponse import roomescape.payment.web.*
import roomescape.payment.web.toCreateResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
private val log = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class PaymentService( class PaymentService(
private val paymentFinder: PaymentFinder, private val paymentClient: TosspayClient,
private val paymentWriter: PaymentWriter private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val paymentWriter: PaymentWriter,
private val transactionExecutionUtil: TransactionExecutionUtil,
) { ) {
@Transactional(readOnly = true) fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse {
fun existsByReservationId(reservationId: Long): Boolean { val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm(
log.debug { "[PaymentService.existsByReservationId] 시작: reservationId=$reservationId" } paymentKey = request.paymentKey,
orderId = request.orderId,
return paymentFinder.existsPaymentByReservationId(reservationId) amount = request.amount,
.also { log.info { "[PaymentService.existsByReservationId] 완료: reservationId=$reservationId, isPaid=$it" } }
}
@Transactional
fun createPayment(
approvedPaymentInfo: PaymentApproveResponse,
reservation: ReservationEntity,
): PaymentCreateResponse {
log.debug { "[PaymentService.createPayment] 시작: paymentKey=${approvedPaymentInfo.paymentKey}, reservationId=${reservation.id}" }
val created: PaymentEntity = paymentWriter.create(
paymentKey = approvedPaymentInfo.paymentKey,
orderId = approvedPaymentInfo.orderId,
totalAmount = approvedPaymentInfo.totalAmount,
approvedAt = approvedPaymentInfo.approvedAt,
reservation = reservation
) )
return created.toCreateResponse() return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
.also { log.info { "[PaymentService.createPayment] 완료: paymentKey=${it.paymentKey}, reservationId=${reservation.id}, paymentId=${it.id}" } } val payment: PaymentEntity = paymentWriter.createPayment(
} reservationId = reservationId,
orderId = request.orderId,
paymentType = request.paymentType,
paymentClientConfirmResponse = clientConfirmResponse
)
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
@Transactional PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
fun createCanceledPayment(
canceledPaymentInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime,
paymentKey: String,
): CanceledPaymentEntity {
log.debug { "[PaymentService.createCanceledPayment] 시작: paymentKey=$paymentKey" }
val created: CanceledPaymentEntity = paymentWriter.createCanceled(
cancelReason = canceledPaymentInfo.cancelReason,
cancelAmount = canceledPaymentInfo.cancelAmount,
canceledAt = canceledPaymentInfo.canceledAt,
approvedAt = approvedAt,
paymentKey = paymentKey
)
return created.also {
log.info { "[PaymentService.createCanceledPayment] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" }
} }
} }
@Transactional fun cancel(memberId: Long, request: PaymentCancelRequest) {
fun createCanceledPayment(reservationId: Long): PaymentCancelRequest { val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
log.debug { "[PaymentService.createCanceledPayment] 시작: reservationId=$reservationId" }
val payment: PaymentEntity = paymentFinder.findByReservationId(reservationId) val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel(
val canceled: CanceledPaymentEntity = paymentWriter.createCanceled( paymentKey = payment.paymentKey,
payment = payment, amount = payment.totalAmount,
cancelReason = "예약 취소", cancelReason = request.cancelReason
canceledAt = OffsetDateTime.now(),
) )
return PaymentCancelRequest(canceled.paymentKey, canceled.cancelAmount, canceled.cancelReason) transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
.also { log.info { "[PaymentService.createCanceledPayment] 완료: reservationId=$reservationId, paymentKey=${it.paymentKey}" } } paymentWriter.cancel(
memberId = memberId,
payment = payment,
requestedAt = request.requestedAt,
cancelResponse = clientCancelResponse
)
}.also {
log.info { "[PaymentService.cancel] 결제 취소 완료: paymentId=${payment.id}" }
}
} }
@Transactional @Transactional(readOnly = true)
fun updateCanceledTime( fun findDetailByReservationId(reservationId: Long): PaymentRetrieveResponse {
paymentKey: String, val payment: PaymentEntity = findByReservationIdOrThrow(reservationId)
canceledAt: OffsetDateTime, val paymentDetail: PaymentDetailEntity = findDetailByPaymentIdOrThrow(payment.id)
) { val cancelDetail: CanceledPaymentEntity? = canceledPaymentRepository.findByPaymentId(payment.id)
log.debug { "[PaymentService.updateCanceledTime] 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" }
paymentFinder.findCanceledByKey(paymentKey).apply { this.canceledAt = canceledAt } return payment.toRetrieveResponse(
detail = paymentDetail.toPaymentDetailResponse(),
cancel = cancelDetail?.toCancelDetailResponse()
)
}
log.info { "[PaymentService.updateCanceledTime] 완료: paymentKey=$paymentKey, canceledAt=$canceledAt" } private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
?.also { log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
?: run {
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
private fun findDetailByPaymentIdOrThrow(paymentId: Long): PaymentDetailEntity {
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId)
?.also { log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" } }
?: run {
log.warn { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
throw PaymentException(PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND)
}
} }
} }

View File

@ -1,4 +1,4 @@
package roomescape.payment.implement package roomescape.payment.business
import com.github.f4b6a3.tsid.TsidFactory import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
@ -7,39 +7,40 @@ import org.springframework.stereotype.Component
import roomescape.common.config.next import roomescape.common.config.next
import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.v2.* import roomescape.payment.infrastructure.client.*
import roomescape.payment.infrastructure.common.PaymentMethod import roomescape.payment.infrastructure.common.PaymentMethod
import roomescape.payment.infrastructure.persistence.v2.* import roomescape.payment.infrastructure.common.PaymentType
import roomescape.reservation.web.ReservationPaymentRequest import roomescape.payment.infrastructure.persistence.*
import java.time.LocalDateTime import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class PaymentWriterV2( class PaymentWriter(
private val paymentRepository: PaymentRepositoryV2, private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository, private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepositoryV2, private val canceledPaymentRepository: CanceledPaymentRepository,
private val tsidFactory: TsidFactory, private val tsidFactory: TsidFactory,
) { ) {
fun createPayment( fun createPayment(
reservationId: Long, reservationId: Long,
request: ReservationPaymentRequest, orderId: String,
paymentConfirmResponse: PaymentConfirmResponse paymentType: PaymentType,
): PaymentEntityV2 { paymentClientConfirmResponse: PaymentClientConfirmResponse
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${request.paymentKey}" } ): PaymentEntity {
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
return paymentConfirmResponse.toEntity( return paymentClientConfirmResponse.toEntity(
id = tsidFactory.next(), reservationId, request.orderId, request.paymentType id = tsidFactory.next(), reservationId, orderId, paymentType
).also { ).also {
paymentRepository.save(it) paymentRepository.save(it)
createDetail(paymentConfirmResponse, it.id) log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, paymentId=${it.id}" }
} }
} }
private fun createDetail( fun createDetail(
paymentResponse: PaymentConfirmResponse, paymentResponse: PaymentClientConfirmResponse,
paymentId: Long, paymentId: Long,
): PaymentDetailEntity { ): PaymentDetailEntity {
val method: PaymentMethod = paymentResponse.method val method: PaymentMethod = paymentResponse.method
@ -57,24 +58,24 @@ class PaymentWriterV2(
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
} }
fun createCanceledPayment( fun cancel(
memberId: Long, memberId: Long,
payment: PaymentEntityV2, payment: PaymentEntity,
requestedAt: LocalDateTime, requestedAt: LocalDateTime,
cancelResponse: PaymentCancelResponseV2 cancelResponse: PaymentClientCancelResponse
) { ): CanceledPaymentEntity {
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 시작: paymentId=${payment.id}, paymentKey=${payment.paymentKey}" } log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
val canceledPayment: CanceledPaymentEntityV2 = cancelResponse.cancels.toEntity( paymentRepository.save(payment.apply { this.cancel() })
return cancelResponse.cancels.toEntity(
id = tsidFactory.next(), id = tsidFactory.next(),
paymentId = payment.id, paymentId = payment.id,
cancelRequestedAt = requestedAt, cancelRequestedAt = requestedAt,
canceledBy = memberId canceledBy = memberId
) ).also {
canceledPaymentRepository.save(it)
canceledPaymentRepository.save(canceledPayment).also { log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
payment.cancel()
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 완료: paymentId=${payment.id}, canceledPaymentId=${it.id}, paymentKey=${payment.paymentKey}" }
} }
} }
} }

View File

@ -0,0 +1,35 @@
package roomescape.payment.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentConfirmRequest
import roomescape.payment.web.PaymentCreateResponse
interface PaymentAPI {
@LoginRequired
@Operation(summary = "결제 승인", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun confirmPayment(
@RequestParam(required = true) reservationId: Long,
@Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentCreateResponse>>
@LoginRequired
@Operation(summary = "결제 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun cancelPayment(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>>
}

View File

@ -1,48 +0,0 @@
package roomescape.payment.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentFinder(
private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
) {
fun existsPaymentByReservationId(reservationId: Long): Boolean {
log.debug { "[PaymentFinder.existsPaymentByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.existsByReservationId(reservationId)
.also { log.debug { "[PaymentFinder.existsPaymentByReservationId] 완료: reservationId=$reservationId, isExist=$it" } }
}
fun findByReservationId(reservationId: Long): PaymentEntity {
log.debug { "[PaymentFinder.findByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.findByReservationId(reservationId)
?.also { log.debug { "[PaymentFinder.findByReservationId] 완료: reservationId=$reservationId" } }
?: run {
log.warn { "[PaymentFinder.findByReservationId] 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
fun findCanceledByKey(paymentKey: String): CanceledPaymentEntity {
log.debug { "[PaymentFinder.findCanceledByKey] 시작: paymentKey=$paymentKey" }
return canceledPaymentRepository.findByPaymentKey(paymentKey)
?.also { log.debug { "[PaymentFinder.findCanceledByKey] 완료: canceledPaymentId=${it.id}" } }
?: run {
log.warn { "[PaymentFinder.findCanceledByKey] 실패: paymentKey=$paymentKey" }
throw PaymentException(PaymentErrorCode.CANCELED_PAYMENT_NOT_FOUND)
}
}
}

View File

@ -1,53 +0,0 @@
package roomescape.payment.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentRepositoryV2
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailRepository
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentRepositoryV2
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentFinderV2(
private val paymentRepository: PaymentRepositoryV2,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepositoryV2
) {
fun findPaymentByReservationId(reservationId: Long): PaymentEntityV2 {
log.debug { "[PaymentFinderV2.findByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.findByReservationId(reservationId)?.also {
log.debug { "[PaymentFinderV2.findByReservationId] 완료: reservationId=$reservationId, paymentId=${it.id}" }
} ?: run {
log.warn { "[PaymentFinderV2.findByReservationId] 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
fun findPaymentDetailByPaymentId(paymentId: Long): PaymentDetailEntity {
log.debug { "[PaymentFinderV2.findPaymentDetailByPaymentId] 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId)?.also {
log.debug { "[PaymentFinderV2.findPaymentDetailByPaymentId] 완료: paymentId=$paymentId, detailId=${it.id}" }
} ?: run {
log.warn { "[PaymentFinderV2.findPaymentDetailByPaymentId] 실패: paymentId=$paymentId" }
throw PaymentException(PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND)
}
}
fun findCanceledPaymentByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntityV2? {
log.debug { "[PaymentFinderV2.findCanceledPaymentByKey] 시작: paymentId=$paymentId" }
return canceledPaymentRepository.findByPaymentId(paymentId)?.also {
log.debug { "[PaymentFinderV2.findCanceledPaymentByKey] 완료: paymentId=$paymentId, canceledPaymentId=${it.id}" }
}
}
}

View File

@ -1,21 +0,0 @@
package roomescape.payment.implement
import org.springframework.stereotype.Component
import roomescape.payment.infrastructure.client.v2.*
@Component
class PaymentRequester(
private val client: TosspaymentClientV2
) {
fun requestConfirmPayment(paymentKey: String, orderId: String, amount: Int): PaymentConfirmResponse {
val request = PaymentConfirmRequest(paymentKey, orderId, amount)
return client.confirm(request)
}
fun requestCancelPayment(paymentKey: String, amount: Int, cancelReason: String): PaymentCancelResponseV2 {
val request = PaymentCancelRequestV2(paymentKey, amount, cancelReason)
return client.cancel(request)
}
}

View File

@ -1,81 +0,0 @@
package roomescape.payment.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentWriter(
private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val tsidFactory: TsidFactory,
) {
fun create(
paymentKey: String,
orderId: String,
totalAmount: Long,
approvedAt: OffsetDateTime,
reservation: ReservationEntity
): PaymentEntity {
log.debug { "[PaymentWriter.create] 시작: paymentKey=${paymentKey}, reservationId=${reservation.id}" }
val payment = PaymentEntity(
_id = tsidFactory.next(),
orderId = orderId,
paymentKey = paymentKey,
totalAmount = totalAmount,
reservation = reservation,
approvedAt = approvedAt
)
return paymentRepository.save(payment)
.also { log.debug { "[PaymentWriter.create] 완료: paymentId=${it.id}, reservationId=${reservation.id}" } }
}
fun createCanceled(
payment: PaymentEntity,
cancelReason: String,
canceledAt: OffsetDateTime,
): CanceledPaymentEntity = createCanceled(
cancelReason = cancelReason,
canceledAt = canceledAt,
cancelAmount = payment.totalAmount,
approvedAt = payment.approvedAt,
paymentKey = payment.paymentKey
)
fun createCanceled(
cancelReason: String,
cancelAmount: Long,
canceledAt: OffsetDateTime,
approvedAt: OffsetDateTime,
paymentKey: String,
): CanceledPaymentEntity {
log.debug { "[PaymentWriter.createCanceled] 시작: paymentKey=$paymentKey cancelAmount=$cancelAmount" }
val canceledPayment = CanceledPaymentEntity(
_id = tsidFactory.next(),
paymentKey = paymentKey,
cancelReason = cancelReason,
cancelAmount = cancelAmount,
approvedAt = approvedAt,
canceledAt = canceledAt
)
return canceledPaymentRepository.save(canceledPayment)
.also {
paymentRepository.deleteByPaymentKey(paymentKey)
log.debug { "[PaymentWriter.createCanceled] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" }
}
}
}

View File

@ -1,29 +0,0 @@
package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.TreeNode
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import roomescape.payment.web.PaymentCancelResponse
import java.time.OffsetDateTime
class PaymentCancelResponseDeserializer(
vc: Class<PaymentCancelResponse>? = null
) : StdDeserializer<PaymentCancelResponse>(vc) {
override fun deserialize(
jsonParser: JsonParser,
deserializationContext: DeserializationContext?
): PaymentCancelResponse {
val cancels: JsonNode = jsonParser.codec.readTree<TreeNode>(jsonParser)
.get("cancels")
.get(0) as JsonNode
return PaymentCancelResponse(
cancels.get("cancelStatus").asText(),
cancels.get("cancelReason").asText(),
cancels.get("cancelAmount").asLong(),
OffsetDateTime.parse(cancels.get("canceledAt").asText())
)
}
}

View File

@ -15,7 +15,7 @@ import java.util.*
class PaymentConfig { class PaymentConfig {
@Bean @Bean
fun tossPaymentClientBuilder( fun tosspayClientBuilder(
paymentProperties: PaymentProperties, paymentProperties: PaymentProperties,
): RestClient.Builder { ): RestClient.Builder {
val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also { val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also {

View File

@ -1,108 +0,0 @@
package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpRequest
import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import java.util.Map
private val log: KLogger = KotlinLogging.logger {}
@Component
class TossPaymentClient(
private val objectMapper: ObjectMapper,
tossPaymentClientBuilder: RestClient.Builder,
) {
companion object {
private const val CONFIRM_URL: String = "/v1/payments/confirm"
private const val CANCEL_URL: String = "/v1/payments/{paymentKey}/cancel"
}
private val tossPaymentClient: RestClient = tossPaymentClientBuilder.build()
fun confirm(paymentRequest: PaymentApproveRequest): PaymentApproveResponse {
logPaymentInfo(paymentRequest)
return tossPaymentClient.post()
.uri(CONFIRM_URL)
.contentType(MediaType.APPLICATION_JSON)
.body(paymentRequest)
.retrieve()
.onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res, "confirm") }
)
.body(PaymentApproveResponse::class.java)
?: run {
log.error { "[TossPaymentClient] 응답 변환 오류" }
throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
fun cancel(cancelRequest: PaymentCancelRequest): PaymentCancelResponse {
logPaymentCancelInfo(cancelRequest)
val param = Map.of<String, String>("cancelReason", cancelRequest.cancelReason)
return tossPaymentClient.post()
.uri(CANCEL_URL, cancelRequest.paymentKey)
.contentType(MediaType.APPLICATION_JSON)
.body(param)
.retrieve()
.onStatus(
{ status: HttpStatusCode -> status.is4xxClientError || status.is5xxServerError },
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res, "cancel") }
)
.body(PaymentCancelResponse::class.java)
?: run {
log.error { "[TossPaymentClient] 응답 변환 오류" }
throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
private fun logPaymentInfo(paymentRequest: PaymentApproveRequest) {
log.info {
"[TossPaymentClient.confirm] 결제 승인 요청: request: $paymentRequest"
}
}
private fun logPaymentCancelInfo(cancelRequest: PaymentCancelRequest) {
log.info {
"[TossPaymentClient.cancel] 결제 취소 요청: request: $cancelRequest"
}
}
private fun handlePaymentError(
res: ClientHttpResponse,
calledBy: String
): Nothing {
getErrorCodeByHttpStatus(res.statusCode).also {
logTossPaymentError(res, calledBy)
throw PaymentException(it)
}
}
private fun logTossPaymentError(res: ClientHttpResponse, calledBy: String): TossPaymentErrorResponse {
val body = res.body
val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java)
body.close()
log.error { "[TossPaymentClient.$calledBy] 요청 실패: response: $errorResponse" }
return errorResponse
}
private fun getErrorCodeByHttpStatus(statusCode: HttpStatusCode): PaymentErrorCode {
if (statusCode.is4xxClientError) {
return PaymentErrorCode.PAYMENT_CLIENT_ERROR
}
return PaymentErrorCode.PAYMENT_PROVIDER_ERROR
}
}

View File

@ -1,22 +0,0 @@
package roomescape.payment.infrastructure.client
import java.time.OffsetDateTime
data class TossPaymentErrorResponse(
val code: String,
val message: String
)
data class PaymentApproveRequest(
val paymentKey: String,
val orderId: String,
val amount: Long,
val paymentType: String
)
data class PaymentApproveResponse(
val paymentKey: String,
val orderId: String,
val totalAmount: Long,
val approvedAt: OffsetDateTime
)

View File

@ -1,22 +1,15 @@
package roomescape.payment.infrastructure.client.v2 package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import roomescape.payment.infrastructure.common.PaymentStatus import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2 import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class PaymentCancelRequestV2( data class PaymentClientCancelResponse(
val paymentKey: String,
val amount: Int,
val cancelReason: String
)
data class PaymentCancelResponseV2(
val status: PaymentStatus, val status: PaymentStatus,
@JsonDeserialize(using = CancelDetailDeserializer::class) @JsonDeserialize(using = CancelDetailDeserializer::class)
val cancels: CancelDetail, val cancels: CancelDetail,
@ -36,7 +29,7 @@ fun CancelDetail.toEntity(
paymentId: Long, paymentId: Long,
canceledBy: Long, canceledBy: Long,
cancelRequestedAt: LocalDateTime cancelRequestedAt: LocalDateTime
) = CanceledPaymentEntityV2( ) = CanceledPaymentEntity(
id = id, id = id,
canceledAt = this.canceledAt, canceledAt = this.canceledAt,
requestedAt = cancelRequestedAt, requestedAt = cancelRequestedAt,
@ -49,7 +42,7 @@ fun CancelDetail.toEntity(
easypayDiscountAmount = this.easyPayDiscountAmount easypayDiscountAmount = this.easyPayDiscountAmount
) )
class CancelDetailDeserializer : JsonDeserializer<CancelDetail>() { class CancelDetailDeserializer : com.fasterxml.jackson.databind.JsonDeserializer<CancelDetail>() {
override fun deserialize( override fun deserialize(
p: JsonParser, p: JsonParser,
ctxt: DeserializationContext ctxt: DeserializationContext

View File

@ -0,0 +1,167 @@
package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.client.ResponseErrorHandler
import org.springframework.web.client.RestClient
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import java.net.URI
private val log: KLogger = KotlinLogging.logger {}
@Component
class TosspayClient(
objectMapper: ObjectMapper,
tosspayClientBuilder: RestClient.Builder
) {
private val confirmClient = ConfirmClient(objectMapper, tosspayClientBuilder.build())
private val cancelClient = CancelClient(objectMapper, tosspayClientBuilder.build())
fun confirm(
paymentKey: String,
orderId: String,
amount: Int,
): PaymentClientConfirmResponse {
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
return confirmClient.request(paymentKey, orderId, amount)
.also {
log.info { "[TosspayClient.confirm] 결제 승인 완료: response=$it" }
}
}
fun cancel(
paymentKey: String,
amount: Int,
cancelReason: String
): PaymentClientCancelResponse {
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
return cancelClient.request(paymentKey, amount, cancelReason).also {
log.info { "[TosspayClient.cancel] 결제 취소 완료: response=$it" }
}
}
}
private class ConfirmClient(
private val objectMapper: ObjectMapper,
private val client: RestClient,
) {
companion object {
private const val CONFIRM_URI: String = "/v1/payments/confirm"
}
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
fun request(paymentKey: String, orderId: String, amount: Int): PaymentClientConfirmResponse {
val response = client.post()
.uri(CONFIRM_URI)
.contentType(MediaType.APPLICATION_JSON)
.body(
mapOf(
"paymentKey" to paymentKey,
"orderId" to orderId,
"amount" to amount
)
)
.retrieve()
.onStatus(errorHandler)
.body(String::class.java)
?: run {
log.error { "[TosspayClient] 응답 바디 변환 실패" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
log.debug { "[TosspayClient.confirm] 응답 수신: json = $response" }
return objectMapper.readValue(response, PaymentClientConfirmResponse::class.java)
}
}
private class CancelClient(
private val objectMapper: ObjectMapper,
private val client: RestClient,
) {
companion object {
private const val CANCEL_URI: String = "/v1/payments/{paymentKey}/cancel"
}
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
fun request(
paymentKey: String,
amount: Int,
cancelReason: String
): PaymentClientCancelResponse {
val response = client.post()
.uri(CANCEL_URI, paymentKey)
.body(
mapOf(
"cancelReason" to cancelReason,
"cancelAmount" to amount,
)
)
.retrieve()
.onStatus(errorHandler)
.body(String::class.java)
?: run {
log.error { "[TosspayClient] 응답 바디 변환 실패" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
log.debug { "[TosspayClient.cancel] 응답 수신: json = $response" }
return objectMapper.readValue(response, PaymentClientCancelResponse::class.java)
}
}
private class TosspayErrorHandler(
private val objectMapper: ObjectMapper
) : ResponseErrorHandler {
override fun hasError(response: ClientHttpResponse): Boolean {
val statusCode: HttpStatusCode = response.statusCode
return statusCode.is4xxClientError || statusCode.is5xxServerError
}
override fun handleError(
url: URI,
method: HttpMethod,
response: ClientHttpResponse
): Nothing {
val requestType: String = paymentRequestType(url)
log.warn { "[TosspayClient] $requestType 요청 실패: response: ${parseResponse(response)}" }
throw PaymentException(paymentErrorCode(response.statusCode))
}
private fun paymentRequestType(url: URI): String {
val type = url.path.split("/").last()
if (type == "cancel") {
return "취소"
}
return "승인"
}
private fun paymentErrorCode(statusCode: HttpStatusCode) = if (statusCode.is4xxClientError) {
PaymentErrorCode.PAYMENT_CLIENT_ERROR
} else {
PaymentErrorCode.PAYMENT_PROVIDER_ERROR
}
private fun parseResponse(response: ClientHttpResponse): TosspayErrorResponse {
val body = response.body
return objectMapper.readValue(body, TosspayErrorResponse::class.java).also {
body.close()
}
}
}

View File

@ -1,21 +1,15 @@
package roomescape.payment.infrastructure.client.v2 package roomescape.payment.infrastructure.client
import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.common.* import roomescape.payment.infrastructure.common.*
import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity import roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity import roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity import roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2 import roomescape.payment.infrastructure.persistence.PaymentEntity
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class PaymentConfirmRequest( data class PaymentClientConfirmResponse(
val paymentKey: String,
val orderId: String,
val amount: Int,
)
data class PaymentConfirmResponse(
val paymentKey: String, val paymentKey: String,
val status: PaymentStatus, val status: PaymentStatus,
val totalAmount: Int, val totalAmount: Int,
@ -29,12 +23,12 @@ data class PaymentConfirmResponse(
val approvedAt: OffsetDateTime, val approvedAt: OffsetDateTime,
) )
fun PaymentConfirmResponse.toEntity( fun PaymentClientConfirmResponse.toEntity(
id: Long, id: Long,
reservationId: Long, reservationId: Long,
orderId: String, orderId: String,
paymentType: PaymentType paymentType: PaymentType
) = PaymentEntityV2( ) = PaymentEntity(
id = id, id = id,
reservationId = reservationId, reservationId = reservationId,
paymentKey = this.paymentKey, paymentKey = this.paymentKey,
@ -58,7 +52,7 @@ data class CardDetail(
val installmentPlanMonths: Int val installmentPlanMonths: Int
) )
fun PaymentConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity { fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return PaymentCardDetailEntity( return PaymentCardDetailEntity(
@ -85,7 +79,7 @@ data class EasyPayDetail(
val discountAmount: Int, val discountAmount: Int,
) )
fun PaymentConfirmResponse.toEasypayPrepaidDetailEntity( fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
id: Long, id: Long,
paymentId: Long paymentId: Long
): PaymentEasypayPrepaidDetailEntity { ): PaymentEasypayPrepaidDetailEntity {
@ -107,7 +101,7 @@ data class TransferDetail(
val settlementStatus: String, val settlementStatus: String,
) )
fun PaymentConfirmResponse.toTransferDetailEntity( fun PaymentClientConfirmResponse.toTransferDetailEntity(
id: Long, id: Long,
paymentId: Long paymentId: Long
): PaymentBankTransferDetailEntity { ): PaymentBankTransferDetailEntity {

View File

@ -0,0 +1,6 @@
package roomescape.payment.infrastructure.client
data class TosspayErrorResponse(
val code: String,
val message: String
)

View File

@ -1,136 +0,0 @@
package roomescape.payment.infrastructure.client.v2
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.client.ResponseErrorHandler
import org.springframework.web.client.RestClient
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.TossPaymentErrorResponse
import java.net.URI
private val log: KLogger = KotlinLogging.logger {}
@Component
class TosspaymentClientV2(
objectMapper: ObjectMapper,
tossPaymentClientBuilder: RestClient.Builder
) {
private val confirmClient = ConfirmClient(objectMapper, tossPaymentClientBuilder.build())
private val cancelClient = CancelClient(objectMapper, tossPaymentClientBuilder.build())
fun confirm(request: PaymentConfirmRequest): PaymentConfirmResponse {
log.info { "[TossPaymentClientV2.confirm] 결제 승인 요청: request=$request" }
return confirmClient.request(request).also {
log.info { "[TossPaymentClientV2.confirm] 결제 승인 완료: response=$it" }
}
}
fun cancel(request: PaymentCancelRequestV2): PaymentCancelResponseV2 {
log.info { "[TossPaymentClient.cancel] 결제 취소 요청: request=$request" }
return cancelClient.request(request).also {
log.info { "[TossPaymentClient.cancel] 결제 취소 완료: response=$it" }
}
}
}
private class ConfirmClient(
objectMapper: ObjectMapper,
private val client: RestClient,
) {
companion object {
private const val CONFIRM_URI: String = "/v1/payments/confirm"
}
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
fun request(request: PaymentConfirmRequest): PaymentConfirmResponse = client.post()
.uri(CONFIRM_URI)
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.onStatus(errorHandler)
.body(PaymentConfirmResponse::class.java) ?: run {
log.error { "[TossPaymentConfirmClient.request] 응답 바디 변환 실패" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
}
private class CancelClient(
objectMapper: ObjectMapper,
private val client: RestClient,
) {
companion object {
private const val CANCEL_URI: String = "/v1/payments/{paymentKey}/cancel"
}
private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper)
fun request(request: PaymentCancelRequestV2): PaymentCancelResponseV2 = client.post()
.uri(CANCEL_URI, request.paymentKey)
.body(
mapOf(
"cancelReason" to request.cancelReason,
"cancelAmount" to request.amount,
)
)
.retrieve()
.onStatus(errorHandler)
.body(PaymentCancelResponseV2::class.java)
?: run {
log.error { "[TossPaymentClient] 응답 바디 변환 실패" }
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
}
private class TosspayErrorHandler(
private val objectMapper: ObjectMapper
) : ResponseErrorHandler {
override fun hasError(response: ClientHttpResponse): Boolean {
val statusCode: HttpStatusCode = response.statusCode
return statusCode.is4xxClientError || statusCode.is5xxServerError
}
override fun handleError(
url: URI,
method: HttpMethod,
response: ClientHttpResponse
): Nothing {
val requestType: String = paymentRequestType(url)
log.warn { "[TossPaymentClient] $requestType 요청 실패: response: ${parseResponse(response)}" }
throw PaymentException(paymentErrorCode(response.statusCode))
}
private fun paymentRequestType(url: URI): String {
val type = url.path.split("/").last()
if (type == "cancel") {
return "취소"
}
return "승인"
}
private fun paymentErrorCode(statusCode: HttpStatusCode) = if (statusCode.is4xxClientError) {
PaymentErrorCode.PAYMENT_CLIENT_ERROR
} else {
PaymentErrorCode.PAYMENT_PROVIDER_ERROR
}
private fun parseResponse(response: ClientHttpResponse): TossPaymentErrorResponse {
val body = response.body
return objectMapper.readValue(body, TossPaymentErrorResponse::class.java).also {
body.close()
}
}
}

View File

@ -23,9 +23,9 @@ enum class PaymentType(
@JsonCreator(mode = JsonCreator.Mode.DELEGATING) @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun get(name: String): PaymentType { fun get(name: String): PaymentType {
return CACHE[name.uppercase()] ?: run { return CACHE[name.uppercase()] ?: run {
log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$name" } log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$name" }
throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND) throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND)
} }
} }
} }
} }
@ -163,9 +163,9 @@ enum class BankCode(
val parsedCode = if (code.length == 2) "0$code" else code val parsedCode = if (code.length == 2) "0$code" else code
return CACHE[parsedCode] ?: run { return CACHE[parsedCode] ?: run {
log.error { "[PaymentCode.BankCode] 은행 코드 조회 실패: code=$code" } log.error { "[PaymentCode.BankCode] 은행 코드 조회 실패: code=$code" }
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND) throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
} }
} }
} }
} }
@ -207,9 +207,9 @@ enum class CardIssuerCode(
@JsonCreator(mode = JsonCreator.Mode.DELEGATING) @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun get(code: String): CardIssuerCode { fun get(code: String): CardIssuerCode {
return CACHE[code] ?: run { return CACHE[code] ?: run {
log.error { "[PaymentCode.CardIssuerCode] 카드사 코드 조회 실패: code=$code" } log.error { "[PaymentCode.CardIssuerCode] 카드사 코드 조회 실패: code=$code" }
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND) throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
} }
} }
} }
} }

View File

@ -1,33 +1,24 @@
package roomescape.payment.infrastructure.persistence package roomescape.payment.infrastructure.persistence
import jakarta.persistence.Column
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table import jakarta.persistence.Table
import roomescape.common.entity.BaseEntity import roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Entity @Entity
@Table(name = "canceled_payments") @Table(name = "canceled_payment")
class CanceledPaymentEntity( class CanceledPaymentEntity(
@Id id: Long,
@Column(name = "canceled_payment_id")
private var _id: Long?,
@Column(name = "payment_key", nullable = false) val paymentId: Long,
var paymentKey: String, val requestedAt: LocalDateTime,
val canceledAt: OffsetDateTime,
val canceledBy: Long,
val cancelReason: String,
val cancelAmount: Int,
val cardDiscountAmount: Int,
val transferDiscountAmount: Int,
val easypayDiscountAmount: Int,
) : PersistableBaseEntity(id)
@Column(name = "cancel_reason", nullable = false)
var cancelReason: String,
@Column(name = "cancel_amount", nullable = false)
var cancelAmount: Long,
@Column(name = "approved_at", nullable = false)
var approvedAt: OffsetDateTime,
@Column(name = "canceled_at", nullable = false)
var canceledAt: OffsetDateTime,
): BaseEntity() {
override fun getId(): Long? = _id
}

View File

@ -3,5 +3,5 @@ package roomescape.payment.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
interface CanceledPaymentRepository : JpaRepository<CanceledPaymentEntity, Long> { interface CanceledPaymentRepository : JpaRepository<CanceledPaymentEntity, Long> {
fun findByPaymentKey(paymentKey: String): CanceledPaymentEntity? fun findByPaymentId(paymentId: Long): CanceledPaymentEntity?
} }

View File

@ -1,9 +1,8 @@
package roomescape.payment.infrastructure.persistence.v2 package roomescape.payment.infrastructure.persistence
import jakarta.persistence.* import jakarta.persistence.*
import roomescape.common.entity.PersistableBaseEntity import roomescape.common.entity.PersistableBaseEntity
import roomescape.payment.infrastructure.common.* import roomescape.payment.infrastructure.common.*
import kotlin.jvm.Transient
@Entity @Entity
@Table(name = "payment_detail") @Table(name = "payment_detail")
@ -14,9 +13,6 @@ open class PaymentDetailEntity(
open val paymentId: Long, open val paymentId: Long,
open val suppliedAmount: Int, open val suppliedAmount: Int,
open val vat: Int, open val vat: Int,
@Transient
private var isNewEntity: Boolean = true
) : PersistableBaseEntity(id) ) : PersistableBaseEntity(id)
@Entity @Entity

View File

@ -0,0 +1,7 @@
package roomescape.payment.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface PaymentDetailRepository : JpaRepository<PaymentDetailEntity, Long> {
fun findByPaymentId(paymentId: Long): PaymentDetailEntity?
}

View File

@ -1,32 +1,38 @@
package roomescape.payment.infrastructure.persistence package roomescape.payment.infrastructure.persistence
import jakarta.persistence.* import jakarta.persistence.Entity
import roomescape.common.entity.BaseEntity import jakarta.persistence.EnumType
import roomescape.reservation.infrastructure.persistence.ReservationEntity import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import roomescape.payment.infrastructure.common.PaymentMethod
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Entity @Entity
@Table(name = "payments") @Table(name = "payment")
class PaymentEntity( class PaymentEntity(
@Id id: Long,
@Column(name = "payment_id")
private var _id: Long?,
@Column(name = "order_id", nullable = false) val reservationId: Long,
var orderId: String, val paymentKey: String,
val orderId: String,
val totalAmount: Int,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
@Column(name="payment_key", nullable = false) @Enumerated(EnumType.STRING)
var paymentKey: String, val type: PaymentType,
@Column(name="total_amount", nullable = false) @Enumerated(EnumType.STRING)
var totalAmount: Long, val method: PaymentMethod,
@OneToOne(fetch = FetchType.LAZY) @Enumerated(EnumType.STRING)
@JoinColumn(name = "reservation_id", nullable = false) var status: PaymentStatus
var reservation: ReservationEntity, ) : PersistableBaseEntity(id) {
@Column(name="approved_at", nullable = false) fun cancel() {
var approvedAt: OffsetDateTime this.status = PaymentStatus.CANCELED
): BaseEntity() { }
override fun getId(): Long? = _id
} }

View File

@ -4,9 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository
interface PaymentRepository : JpaRepository<PaymentEntity, Long> { interface PaymentRepository : JpaRepository<PaymentEntity, Long> {
fun existsByReservationId(reservationId: Long): Boolean
fun findByReservationId(reservationId: Long): PaymentEntity? fun findByReservationId(reservationId: Long): PaymentEntity?
fun deleteByPaymentKey(paymentKey: String)
} }

View File

@ -1,24 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import jakarta.persistence.Entity
import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime
@Entity
@Table(name = "canceled_payment1")
class CanceledPaymentEntityV2(
id: Long,
val paymentId: Long,
val requestedAt: LocalDateTime,
val canceledAt: OffsetDateTime,
val canceledBy: Long,
val cancelReason: String,
val cancelAmount: Int,
val cardDiscountAmount: Int,
val transferDiscountAmount: Int,
val easypayDiscountAmount: Int,
) : PersistableBaseEntity(id)

View File

@ -1,7 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface CanceledPaymentRepositoryV2 : JpaRepository<CanceledPaymentEntityV2, Long> {
fun findByPaymentId(paymentId: Long): CanceledPaymentEntityV2?
}

View File

@ -1,7 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface PaymentDetailRepository: JpaRepository<PaymentDetailEntity, Long> {
fun findByPaymentId(paymentId: Long) : PaymentDetailEntity?
}

View File

@ -1,38 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import roomescape.payment.infrastructure.common.PaymentMethod
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType
import java.time.OffsetDateTime
@Entity
@Table(name = "payment1")
class PaymentEntityV2(
id: Long,
val reservationId: Long,
val paymentKey: String,
val orderId: String,
val totalAmount: Int,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
@Enumerated(EnumType.STRING)
val type: PaymentType,
@Enumerated(EnumType.STRING)
val method: PaymentMethod,
@Enumerated(EnumType.STRING)
var status: PaymentStatus
) : PersistableBaseEntity(id) {
fun cancel() {
this.status = PaymentStatus.CANCELED
}
}

View File

@ -1,8 +0,0 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface PaymentRepositoryV2: JpaRepository<PaymentEntityV2, Long> {
fun findByReservationId(reservationId: Long): PaymentEntityV2?
}

View File

@ -0,0 +1,39 @@
package roomescape.payment.web
import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.business.PaymentService
import roomescape.payment.docs.PaymentAPI
@RestController
class PaymentController(
private val paymentService: PaymentService
) : PaymentAPI {
@PostMapping("/payments")
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("/payments/cancel")
override fun cancelPayment(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody request: PaymentCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> {
paymentService.cancel(memberId, request)
return ResponseEntity.ok(CommonApiResponse())
}
}

View File

@ -1,38 +1,136 @@
package roomescape.payment.web package roomescape.payment.web
import com.fasterxml.jackson.databind.annotation.JsonDeserialize import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.PaymentEntity import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType
import roomescape.payment.infrastructure.persistence.*
import roomescape.payment.web.PaymentDetailResponse.*
import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class PaymentCancelRequest( data class PaymentConfirmRequest(
val paymentKey: String, val paymentKey: String,
val amount: Long, val orderId: String,
val cancelReason: String val amount: Int,
) val paymentType: PaymentType
@JsonDeserialize(using = PaymentCancelResponseDeserializer::class)
data class PaymentCancelResponse(
val cancelStatus: String,
val cancelReason: String,
val cancelAmount: Long,
val canceledAt: OffsetDateTime
) )
data class PaymentCreateResponse( data class PaymentCreateResponse(
val id: Long, val paymentId: Long,
val orderId: String, val detailId: Long
val paymentKey: String,
val totalAmount: Long,
val reservationId: Long,
val approvedAt: OffsetDateTime
) )
fun PaymentEntity.toCreateResponse() = PaymentCreateResponse( data class PaymentCancelRequest(
id = this.id!!, val reservationId: Long,
orderId = this.orderId, val cancelReason: String,
paymentKey = this.paymentKey, val requestedAt: LocalDateTime = LocalDateTime.now()
totalAmount = this.totalAmount,
reservationId = this.reservation.id!!,
approvedAt = this.approvedAt
) )
data class PaymentRetrieveResponse(
val orderId: String,
val totalAmount: Int,
val method: String,
val status: PaymentStatus,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val detail: PaymentDetailResponse,
val cancel: PaymentCancelDetailResponse?,
)
fun PaymentEntity.toRetrieveResponse(
detail: PaymentDetailResponse,
cancel: PaymentCancelDetailResponse?
): PaymentRetrieveResponse {
return PaymentRetrieveResponse(
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: LocalDateTime,
val cancellationApprovedAt: OffsetDateTime?,
val cancelReason: String,
val canceledBy: Long,
)
fun CanceledPaymentEntity.toCancelDetailResponse(): PaymentCancelDetailResponse {
return PaymentCancelDetailResponse(
cancellationRequestedAt = this.requestedAt,
cancellationApprovedAt = this.canceledAt,
cancelReason = this.cancelReason,
canceledBy = this.canceledBy
)
}

View File

@ -1,54 +0,0 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.payment.implement.PaymentFinderV2
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.web.ReservationDetailRetrieveResponse
import roomescape.reservation.web.ReservationSummaryRetrieveListResponse
import roomescape.reservation.web.toCancelDetailResponse
import roomescape.reservation.web.toPaymentDetailResponse
import roomescape.reservation.web.toReservationDetailRetrieveResponse
import roomescape.reservation.web.toRetrieveResponse
import roomescape.reservation.web.toSummaryListResponse
private val log: KLogger = KotlinLogging.logger {}
@Service
class MyReservationFindService(
private val reservationFinder: ReservationFinder,
private val paymentFinder: PaymentFinderV2
) {
@Transactional(readOnly = true)
fun findReservationsByMemberId(memberId: Long): ReservationSummaryRetrieveListResponse {
log.debug { "[ReservationFindServiceV2.findReservationsByMemberId] 시작: memberId=$memberId" }
return reservationFinder.findAllByMemberIdV2(memberId)
.toSummaryListResponse()
.also { log.info { "[ReservationFindServiceV2.findReservationsByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=$memberId" } }
}
@Transactional(readOnly = true)
fun showReservationDetails(reservationId: Long): ReservationDetailRetrieveResponse {
log.debug { "[ReservationFindServiceV2.showReservationDetails] 시작: reservationId=$reservationId" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
val payment: PaymentEntityV2 = paymentFinder.findPaymentByReservationId(reservationId)
val paymentDetail: PaymentDetailEntity = paymentFinder.findPaymentDetailByPaymentId(payment.id)
val canceledPayment: CanceledPaymentEntityV2? = paymentFinder.findCanceledPaymentByPaymentIdOrNull(payment.id)
return reservation.toReservationDetailRetrieveResponse(
payment = payment.toRetrieveResponse(detail = paymentDetail.toPaymentDetailResponse()),
cancellation = canceledPayment?.toCancelDetailResponse()
).also {
log.info { "[ReservationFindServiceV2.showReservationDetails] 예약 상세 조회 완료: reservationId=$reservationId" }
}
}
}

View File

@ -1,56 +0,0 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.MyReservationRetrieveListResponse
import roomescape.reservation.web.ReservationRetrieveListResponse
import roomescape.reservation.web.toRetrieveListResponse
import java.time.LocalDate
private val log = KotlinLogging.logger {}
@Service
@Transactional(readOnly = true)
class ReservationFindService(
private val reservationFinder: ReservationFinder
) {
fun findReservations(): ReservationRetrieveListResponse {
log.debug { "[ReservationService.findReservations] 시작" }
return reservationFinder.findAllByStatuses(*ReservationStatus.confirmedStatus())
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findReservations] ${it.reservations.size}개의 예약 조회 완료" } }
}
fun findAllWaiting(): ReservationRetrieveListResponse {
log.debug { "[ReservationService.findAllWaiting] 시작" }
return reservationFinder.findAllByStatuses(ReservationStatus.WAITING)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findAllWaiting] ${it.reservations.size}개의 대기 조회 완료" } }
}
fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse {
log.debug { "[ReservationService.findReservationsByMemberId] 시작: memberId=$memberId" }
return reservationFinder.findAllByMemberId(memberId)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findReservationsByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=$memberId" } }
}
fun searchReservations(
themeId: Long?,
memberId: Long?,
startFrom: LocalDate?,
endAt: LocalDate?,
): ReservationRetrieveListResponse {
log.debug { "[ReservationService.searchReservations] 시작: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" }
return reservationFinder.searchReservations(themeId, memberId, startFrom, endAt)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.searchReservations] ${it.reservations.size}개의 예약 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" } }
}
}

View File

@ -0,0 +1,168 @@
package roomescape.reservation.business
import com.github.f4b6a3.tsid.TsidFactory
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 roomescape.common.config.next
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.MemberSummaryRetrieveResponse
import roomescape.payment.business.PaymentService
import roomescape.payment.web.PaymentRetrieveResponse
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.*
import roomescape.reservation.web.*
import roomescape.schedule.business.ScheduleService
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleSummaryResponse
import roomescape.schedule.web.ScheduleUpdateRequest
import roomescape.theme.business.ThemeService
import roomescape.theme.web.ThemeSummaryResponse
import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {}
@Service
class ReservationService(
private val reservationRepository: ReservationRepository,
private val reservationValidator: ReservationValidator,
private val scheduleService: ScheduleService,
private val memberService: MemberService,
private val themeService: ThemeService,
private val canceledReservationRepository: CanceledReservationRepository,
private val tsidFactory: TsidFactory,
private val paymentService: PaymentService
) {
@Transactional
fun createPendingReservation(
memberId: Long,
request: PendingReservationCreateRequest
): PendingReservationCreateResponse {
log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
validateCanCreate(request)
val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), memberId = memberId)
return PendingReservationCreateResponse(reservationRepository.save(reservation).id)
.also { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" }
}
@Transactional
fun confirmReservation(id: Long) {
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id)
run {
reservation.confirm()
scheduleService.updateSchedule(
reservation.scheduleId,
ScheduleUpdateRequest(status = ScheduleStatus.RESERVED)
)
}.also {
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 완료: reservationId=${id}" }
}
}
@Transactional
fun cancelReservation(memberId: Long, reservationId: Long, request: ReservationCancelRequest) {
log.info { "[ReservationService.cancelReservation] 예약 취소 시작: memberId=${memberId}, reservationId=${reservationId}" }
val reservation: ReservationEntity = findOrThrow(reservationId)
val member: MemberSummaryRetrieveResponse = memberService.findSummaryById(memberId)
run {
scheduleService.updateSchedule(
reservation.scheduleId,
ScheduleUpdateRequest(status = ScheduleStatus.AVAILABLE)
)
saveCanceledReservation(member, reservation, request.cancelReason)
reservation.cancel()
}.also {
log.info { "[ReservationService.cancelReservation] 예약 취소 완료: reservationId=${reservationId}" }
}
}
@Transactional(readOnly = true)
fun findSummaryByMemberId(memberId: Long): ReservationSummaryRetrieveListResponse {
log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: memberId=${memberId}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByMemberId(memberId)
return ReservationSummaryRetrieveListResponse(reservations.map {
val schedule: ScheduleSummaryResponse = scheduleService.findSummaryById(it.scheduleId)
val theme: ThemeSummaryResponse = themeService.findSummaryById(schedule.themeId)
ReservationSummaryRetrieveResponse(
id = it.id,
themeName = theme.name,
date = schedule.date,
startAt = schedule.time,
status = it.status
)
}).also {
log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=${memberId}" }
}
}
@Transactional(readOnly = true)
fun findDetailById(id: Long): ReservationDetailRetrieveResponse {
log.info { "[ReservationService.findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id)
val member: MemberSummaryRetrieveResponse = memberService.findSummaryById(reservation.memberId)
val paymentDetail: PaymentRetrieveResponse = paymentService.findDetailByReservationId(id)
return reservation.toReservationDetailRetrieveResponse(
member = member,
payment = paymentDetail
).also {
log.info { "[ReservationService.findDetailById] 예약 상세 조회 완료: reservationId=${id}" }
}
}
private fun findOrThrow(id: Long): ReservationEntity {
log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdOrNull(id)
?.also { log.info { "[ReservationService.findOrThrow] 예약 조회 완료: reservationId=${id}" } }
?: run {
log.warn { "[ReservationService.findOrThrow] 예약 조회 실패: reservationId=${id}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
private fun saveCanceledReservation(
member: MemberSummaryRetrieveResponse,
reservation: ReservationEntity,
cancelReason: String
) {
if (member.role != Role.ADMIN && reservation.memberId != member.id) {
log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, memberId=${member.id}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION)
}
CanceledReservationEntity(
id = tsidFactory.next(),
reservationId = reservation.id,
canceledBy = member.id,
cancelReason = cancelReason,
canceledAt = LocalDateTime.now(),
status = CanceledReservationStatus.PROCESSING
).also {
canceledReservationRepository.save(it)
}
}
private fun validateCanCreate(request: PendingReservationCreateRequest) {
val schedule = scheduleService.findSummaryById(request.scheduleId)
val theme = themeService.findSummaryById(schedule.themeId)
reservationValidator.validateCanCreate(schedule, theme, request)
}
}

View File

@ -0,0 +1,38 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.web.PendingReservationCreateRequest
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleSummaryResponse
import roomescape.theme.web.ThemeSummaryResponse
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationValidator {
fun validateCanCreate(
schedule: ScheduleSummaryResponse,
theme: ThemeSummaryResponse,
request: PendingReservationCreateRequest
) {
if (schedule.status != ScheduleStatus.HOLD) {
log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}인 일정으로 인한 예약 실패" }
throw ReservationException(ReservationErrorCode.SCHEDULE_NOT_HOLD)
}
if (theme.minParticipants > request.participantCount) {
log.info { "[ReservationValidator.validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
}
if (theme.maxParticipants < request.participantCount) {
log.info { "[ReservationValidator.validateCanCreate] 최대 인원 초과로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" }
throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT)
}
}
}

View File

@ -1,75 +0,0 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.payment.business.PaymentService
import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.web.ReservationCreateResponse
import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.toCreateResponse
import java.time.OffsetDateTime
private val log = KotlinLogging.logger {}
@Service
@Transactional
class ReservationWithPaymentService(
private val reservationWriteService: ReservationWriteService,
private val paymentService: PaymentService,
) {
fun createReservationAndPayment(
request: ReservationCreateWithPaymentRequest,
approvedPaymentInfo: PaymentApproveResponse,
memberId: Long,
): ReservationCreateResponse {
log.info { "[ReservationWithPaymentService.createReservationAndPayment] 시작: memberId=$memberId, paymentInfo=$approvedPaymentInfo" }
val reservation: ReservationEntity = reservationWriteService.createReservationWithPayment(request, memberId)
.also { paymentService.createPayment(approvedPaymentInfo, it) }
return reservation.toCreateResponse()
.also { log.info { "[ReservationWithPaymentService.createReservationAndPayment] 완료: reservationId=${reservation.id}, paymentId=${it.id}" } }
}
fun createCanceledPayment(
canceledPaymentInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime,
paymentKey: String,
) {
paymentService.createCanceledPayment(canceledPaymentInfo, approvedAt, paymentKey)
}
fun deleteReservationAndPayment(
reservationId: Long,
memberId: Long,
): PaymentCancelRequest {
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 시작: reservationId=$reservationId" }
val paymentCancelRequest = paymentService.createCanceledPayment(reservationId)
reservationWriteService.deleteReservation(reservationId, memberId)
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 완료: reservationId=$reservationId" }
return paymentCancelRequest
}
@Transactional(readOnly = true)
fun isNotPaidReservation(reservationId: Long): Boolean {
log.info { "[ReservationWithPaymentService.isNotPaidReservation] 시작: reservationId=$reservationId" }
val notPaid: Boolean = !paymentService.existsByReservationId(reservationId)
return notPaid.also {
log.info { "[ReservationWithPaymentService.isNotPaidReservation] 완료: reservationId=$reservationId, isPaid=${notPaid}" }
}
}
fun updateCanceledTime(
paymentKey: String,
canceledAt: OffsetDateTime,
) {
paymentService.updateCanceledTime(paymentKey, canceledAt)
}
}

View File

@ -1,108 +0,0 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import roomescape.common.util.TransactionExecutionUtil
import roomescape.payment.implement.PaymentFinderV2
import roomescape.payment.implement.PaymentRequester
import roomescape.payment.implement.PaymentWriterV2
import roomescape.payment.infrastructure.client.v2.PaymentConfirmResponse
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.implement.ReservationWriter
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.*
private val log: KLogger = KotlinLogging.logger {}
@Service
class ReservationWithPaymentServiceV2(
private val reservationWriter: ReservationWriter,
private val reservationFinder: ReservationFinder,
private val paymentRequester: PaymentRequester,
private val paymentFinder: PaymentFinderV2,
private val paymentWriter: PaymentWriterV2,
private val transactionExecutionUtil: TransactionExecutionUtil,
) {
@Transactional
fun createPendingReservation(memberId: Long, request: ReservationCreateRequest): ReservationCreateResponseV2 {
log.info {
"[ReservationWithPaymentServiceV2.createPendingReservation] " +
"PENDING 예약 저장 시작: memberId=$memberId, request=$request"
}
val reservation: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.PENDING,
memberId = memberId,
requesterId = memberId
)
return reservation.toCreateResponseV2().also {
log.info {
"[ReservationWithPaymentServiceV2.createPendingReservation] " +
"PENDING 예약 저장 완료: reservationId=${reservation.id}, response=$it"
}
}
}
fun payReservation(
memberId: Long,
reservationId: Long,
request: ReservationPaymentRequest
): ReservationPaymentResponse {
log.info {
"[ReservationWithPaymentServiceV2.payReservation] " +
"예약 결제 시작: memberId=$memberId, reservationId=$reservationId, request=$request"
}
val paymentConfirmResponse: PaymentConfirmResponse = paymentRequester.requestConfirmPayment(
paymentKey = request.paymentKey,
orderId = request.orderId,
amount = request.amount
)
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
val payment: PaymentEntityV2 = paymentWriter.createPayment(reservationId, request, paymentConfirmResponse)
val reservation: ReservationEntity =
reservationWriter.modifyStatusFromPendingToConfirmed(reservationId, memberId)
ReservationPaymentResponse(reservationId, reservation.status, payment.id, payment.status)
.also { log.info { "[ReservationWithPaymentServiceV2.payReservation] 예약 결제 완료: response=${it}" } }
}
}
fun cancelReservation(
memberId: Long,
reservationId: Long,
request: ReservationCancelRequest
) {
log.info {
"[ReservationWithPaymentServiceV2.cancelReservation] " +
"예약 취소 시작: memberId=$memberId, reservationId=$reservationId, request=$request"
}
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
val payment: PaymentEntityV2 = paymentFinder.findPaymentByReservationId(reservationId)
val paymentCancelResponse = paymentRequester.requestCancelPayment(
paymentKey = payment.paymentKey,
amount = payment.totalAmount,
cancelReason = request.cancelReason
)
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
paymentWriter.createCanceledPayment(memberId, payment, request.requestedAt, paymentCancelResponse)
reservationWriter.modifyStatusToCanceledByUser(reservation, memberId)
}.also {
log.info {
"[ReservationWithPaymentServiceV2.cancelReservation] " +
"예약 취소 완료: reservationId=$reservationId, memberId=$memberId, cancelReason=${request.cancelReason}"
}
}
}
}

View File

@ -1,104 +0,0 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.implement.ReservationWriter
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.*
private val log: KLogger = KotlinLogging.logger {}
@Service
@Transactional
class ReservationWriteService(
private val reservationFinder: ReservationFinder,
private val reservationWriter: ReservationWriter
) {
fun createReservationWithPayment(
request: ReservationCreateWithPaymentRequest,
memberId: Long
): ReservationEntity {
log.debug { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED,
memberId = memberId,
requesterId = memberId
)
return created.also {
log.info { "[ReservationCommandService.createReservationByAdmin] 완료: reservationId=${it.id}" }
}
}
fun createReservationByAdmin(
request: AdminReservationCreateRequest,
memberId: Long
): ReservationCreateResponse {
log.debug { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${request.memberId} by adminId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED,
memberId = request.memberId,
requesterId = memberId
)
return created.toCreateResponse()
.also {
log.info { "[ReservationCommandService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" }
}
}
fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationCreateResponse {
log.debug { "[ReservationCommandService.createWaiting] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.WAITING,
memberId = memberId,
requesterId = memberId
)
return created.toCreateResponse()
.also {
log.info { "[ReservationCommandService.createWaiting] 완료: reservationId=${it.id}" }
}
}
fun deleteReservation(reservationId: Long, memberId: Long) {
log.debug { "[ReservationCommandService.deleteReservation] 시작: reservationId=${reservationId}, memberId=$memberId" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
reservationWriter.deleteConfirmed(reservation, requesterId = memberId)
.also { log.info { "[ReservationCommandService.deleteReservation] 완료: reservationId=${reservationId}" } }
}
fun confirmWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationCommandService.confirmWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" }
reservationWriter.confirm(reservationId)
.also { log.info { "[ReservationCommandService.confirmWaiting] 완료: reservationId=$reservationId" } }
}
fun deleteWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationCommandService.deleteWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
reservationWriter.deleteWaiting(reservation, requesterId = memberId)
.also { log.info { "[ReservationCommandService.deleteWaiting] 완료: reservationId=$reservationId" } }
}
}

View File

@ -1,33 +0,0 @@
package roomescape.reservation.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
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 roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.ReservationDetailRetrieveResponse
import roomescape.reservation.web.ReservationSummaryRetrieveListResponse
interface MyReservationAPI {
@LoginRequired
@Operation(summary = "내 예약 개요 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun findAllMyReservations(
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>>
@LoginRequired
@Operation(summary = "예약 상세 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun showReservationDetails(
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>>
}

View File

@ -2,150 +2,54 @@ package roomescape.reservation.docs
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.headers.Header
import io.swagger.v3.oas.annotations.media.Schema
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 io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.* import roomescape.reservation.web.*
import java.time.LocalDate
@Tag(name = "3. 예약 API", description = "예약 및 대기 정보를 추가 / 조회 / 삭제할 때 사용합니다.")
interface ReservationAPI { interface ReservationAPI {
@Admin @LoginRequired
@Operation(summary = "모든 예약 정보 조회", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> fun createPendingReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody request: PendingReservationCreateRequest
): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>>
@LoginRequired @LoginRequired
@Operation(summary = "자신의 예약 및 대기 조회", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 확정", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findReservationsByMemberId( fun confirmReservation(
@MemberId @Parameter(hidden = true) memberId: Long @PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>>
@Admin
@Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
)
fun searchReservations(
@RequestParam(required = false) themeId: Long?,
@RequestParam(required = false) memberId: Long?,
@RequestParam(required = false) dateFrom: LocalDate?,
@RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>>
@Admin
@Operation(summary = "관리자의 예약 취소", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "성공"),
)
fun cancelReservationByAdmin(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "201",
description = "성공",
useReturnTypeSchema = true,
headers = [Header(
name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)]
)
)
fun createReservationWithPayment(
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@Admin
@Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "201",
description = "성공",
useReturnTypeSchema = true,
headers = [Header(
name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)],
)
)
fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@Admin
@Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> fun cancelReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable reservationId: Long,
@Valid @RequestBody request: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 대기 신청", tags = ["로그인이 필요한 API"]) @Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
ApiResponse( fun findSummaryByMemberId(
responseCode = "201", @MemberId @Parameter(hidden = true) memberId: Long
description = "성공", ): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>>
useReturnTypeSchema = true,
headers = [Header(
name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)]
)
)
fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"]) @Operation(summary = "특정 예약에 대한 상세 조회", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
ApiResponse(responseCode = "204", description = "성공"), fun findDetailById(
) @PathVariable("id") id: Long
fun cancelWaitingByMember( ): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>>
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>>
@Admin
@Operation(summary = "대기 중인 예약 승인", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun confirmWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>>
@Admin
@Operation(summary = "대기 중인 예약 거절", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"),
)
fun rejectWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>>
} }

View File

@ -1,61 +0,0 @@
package roomescape.reservation.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.headers.Header
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.*
interface ReservationWithPaymentAPI {
@LoginRequired
@Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "200",
description = "성공",
useReturnTypeSchema = true,
headers = [Header(
name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)]
)
)
fun createPendingReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateRequest
): ResponseEntity<CommonApiResponse<ReservationCreateResponseV2>>
@LoginRequired
@Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "성공"),
)
fun cancelReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody cancelRequest: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired
@Operation(summary = "예약 결제", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun createPaymentAndConfirmReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody request: ReservationPaymentRequest
): ResponseEntity<CommonApiResponse<ReservationPaymentResponse>>
}

View File

@ -9,14 +9,9 @@ enum class ReservationErrorCode(
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."), RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."),
RESERVATION_DUPLICATED(HttpStatus.BAD_REQUEST, "R002", "이미 같은 예약이 있어요."), NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
ALREADY_RESERVE(HttpStatus.BAD_REQUEST, "R003", "같은 날짜, 시간, 테마에 대한 예약(대기)는 한 번만 가능해요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
ALREADY_CONFIRMED(HttpStatus.CONFLICT, "R004", "이미 확정된 예약이에요"), SCHEDULE_NOT_HOLD(HttpStatus.BAD_REQUEST, "R004", "이미 예약되었거나 예약이 불가능한 일정이에요."),
CONFIRMED_RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "R005", "이미 확정된 예약이 있어서 승인할 수 없어요."), INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.")
PAST_REQUEST_DATETIME(HttpStatus.BAD_REQUEST, "R005", "과거 시간으로 예약할 수 없어요."),
NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."),
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."),
NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."),
RESERVATION_NOT_PENDING(HttpStatus.BAD_REQUEST, "R009", "결제 대기 중인 예약이 아니에요."),
; ;
} }

View File

@ -1,113 +0,0 @@
package roomescape.reservation.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.MyReservationRetrieveResponse
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationFinder(
private val reservationRepository: ReservationRepository,
private val reservationValidator: ReservationValidator,
) {
fun findById(id: Long): ReservationEntity {
log.debug { "[ReservationFinder.findById] 시작: id=$id" }
return reservationRepository.findByIdOrNull(id)
?.also { log.debug { "[ReservationFinder.findById] 완료: reservationId=$id, date:${it.date}, timeId:${it.time.id}, themeId:${it.theme.id}" } }
?: run {
log.warn { "[ReservationFinder.findById] 조회 실패: reservationId=$id" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
fun findAllByStatuses(vararg statuses: ReservationStatus): List<ReservationEntity> {
log.debug { "[ReservationFinder.findAll] 시작: status=${statuses}" }
val spec = ReservationSearchSpecification()
.status(*statuses)
.build()
return reservationRepository.findAll(spec)
.also { log.debug { "[ReservationFinder.findAll] ${it.size}개 예약 조회 완료: status=${statuses}" } }
}
fun findAllByDateAndTheme(
date: LocalDate, theme: ThemeEntity
): List<ReservationEntity> {
log.debug { "[ReservationFinder.findAllByDateAndTheme] 시작: date=$date, themeId=${theme.id}" }
return reservationRepository.findAllByDateAndTheme(date, theme)
.also { log.debug { "[ReservationFinder.findAllByDateAndTheme] ${it.size}개 조회 완료: date=$date, themeId=${theme.id}" } }
}
fun findAllByMemberIdV2(memberId: Long): List<ReservationEntity> {
log.debug { "[ReservationFinder.findAllByMember] 시작: memberId=${memberId}" }
return reservationRepository.findAllByMember_Id(memberId)
.filter { it.status == ReservationStatus.CONFIRMED || it.status == ReservationStatus.CANCELED_BY_USER }
.sortedByDescending { it.date }
.also { log.debug { "[ReservationFinder.findAllByMember] ${it.size}개 예약 조회 완료: memberId=${memberId}" } }
}
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse> {
log.debug { "[ReservationFinder.findAllByMemberId] 시작: memberId=${memberId}" }
return reservationRepository.findAllByMemberId(memberId)
.also { log.debug { "[ReservationFinder.findAllByMemberId] ${it.size}개 예약(대기) 조회 완료: memberId=${memberId}" } }
}
fun searchReservations(
themeId: Long?,
memberId: Long?,
startFrom: LocalDate?,
endAt: LocalDate?,
): List<ReservationEntity> {
reservationValidator.validateSearchDateRange(startFrom, endAt)
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameThemeId(themeId)
.sameMemberId(memberId)
.dateStartFrom(startFrom)
.dateEndAt(endAt)
.status(ReservationStatus.CONFIRMED, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
.build()
return reservationRepository.findAll(spec)
.also {
log.debug { "[ReservationFinder.searchReservations] ${it.size}개 예약 조회 완료. " +
"themeId=${themeId}, memberId=${memberId}, startFrom=${startFrom}, endAt=${endAt}" }
}
}
fun isTimeReserved(time: TimeEntity): Boolean {
log.debug { "[ReservationFinder.isTimeReserved] 시작: timeId=${time.id}, startAt=${time.startAt}" }
return reservationRepository.existsByTime(time)
.also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } }
}
fun findPendingReservation(reservationId: Long, memberId: Long): ReservationEntity {
log.debug { "[ReservationFinder.findPendingReservationIfExists] 시작: reservationId=$reservationId, memberId=$memberId" }
return findById(reservationId).also {
reservationValidator.validateIsReservedByMemberAndPending(it, memberId)
}.also {
log.debug { "[ReservationFinder.findPendingReservationIfExists] 완료: reservationId=${it.id}, status=${it.status}" }
}
}
}

View File

@ -1,178 +0,0 @@
package roomescape.reservation.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification
import org.springframework.stereotype.Component
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationValidator(
private val reservationRepository: ReservationRepository,
) {
fun validateIsPast(
requestDate: LocalDate,
requestTime: LocalTime,
) {
val now = LocalDateTime.now()
val requestDateTime = LocalDateTime.of(requestDate, requestTime)
log.debug { "[ReservationValidator.validateIsPast] 시작. request=$requestDateTime, now=$now" }
if (requestDateTime.isBefore(now)) {
log.info { "[ReservationValidator.validateIsPast] 날짜 범위 오류. request=$requestDateTime, now=$now" }
throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME)
}
log.debug { "[ReservationValidator.validateIsPast] 완료. request=$requestDateTime, now=$now" }
}
fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) {
log.debug { "[ReservationValidator.validateSearchDateRange] 시작: startFrom=$startFrom, endAt=$endAt" }
if (startFrom == null || endAt == null) {
log.debug { "[ReservationValidator.validateSearchDateRange] 완료: startFrom=$startFrom, endAt=$endAt" }
return
}
if (startFrom.isAfter(endAt)) {
log.info { "[ReservationValidator.validateSearchDateRange] 날짜 범위 오류: startFrom=$startFrom, endAt=$endAt" }
throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE)
}
log.debug { "[ReservationValidator.validateSearchDateRange] 완료: startFrom=$startFrom, endAt=$endAt" }
}
fun validateIsAlreadyExists(date: LocalDate, time: TimeEntity, theme: ThemeEntity) {
val themeId = theme.id
val timeId = time.id
log.debug { "[ReservationValidator.validateIsAlreadyExists] 시작: date=$date, timeId=$timeId, themeId=$themeId" }
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationValidator.validateIsAlreadyExists] 중복된 예약 존재: date=$date, timeId=$timeId, themeId=$themeId" }
throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED)
}
log.debug { "[ReservationValidator.validateIsAlreadyExists] 완료: date=$date, timeId=$timeId, themeId=$themeId" }
}
fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, requesterId: Long) {
log.debug { "[ReservationValidator.validateMemberAlreadyReserve] 시작: themeId=$themeId, timeId=$timeId, date=$date, requesterId=$requesterId" }
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameMemberId(requesterId)
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationValidator.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.ALREADY_RESERVE)
}
log.debug { "[ReservationValidator.validateMemberAlreadyReserve] 완료: themeId=$themeId, timeId=$timeId, date=$date, requesterId=$requesterId" }
}
fun validateIsWaiting(reservation: ReservationEntity) {
log.debug { "[ReservationValidator.validateIsWaiting] 시작: reservationId=${reservation.id}, status=${reservation.status}" }
if (!reservation.isWaiting()) {
log.warn { "[ReservationValidator.validateIsWaiting] 대기 상태가 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}
log.debug { "[ReservationValidator.validateIsWaiting] 완료: reservationId=${reservation.id}, status=${reservation.status}" }
}
fun validateCreateAuthority(requester: MemberEntity) {
log.debug { "[ReservationValidator.validateCreateAuthority] 시작: requesterId=${requester.id}" }
if (!requester.isAdmin()) {
log.error { "[ReservationValidator.validateCreateAuthority] 관리자가 아닌 다른 회원의 예약 시도: requesterId=${requester.id}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION)
}
log.debug { "[ReservationValidator.validateCreateAuthority] 완료: requesterId=${requester.id}" }
}
fun validateDeleteAuthority(reservation: ReservationEntity, requester: MemberEntity) {
val requesterId: Long = requester.id!!
log.debug { "[ReservationValidator.validateDeleteAuthority] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" }
if (requester.isAdmin()) {
log.debug { "[ReservationValidator.validateDeleteAuthority] 완료: reservationId=${reservation.id} requesterId=${requesterId}(Admin)" }
return
}
if (!reservation.isReservedBy(requesterId)) {
log.error {
"[ReservationValidator.validateDeleteAuthority] 예약자 본인이 아님: reservationId=${reservation.id}" +
", memberId=${reservation.member.id} requesterId=${requesterId} "
}
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
log.debug { "[ReservationValidator.validateDeleteAuthority] 완료: reservationId=${reservation.id}, requesterId=$requesterId" }
}
fun validateAlreadyConfirmed(reservationId: Long) {
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 시작: reservationId=$reservationId" }
if (reservationRepository.isExistConfirmedReservation(reservationId)) {
log.warn { "[ReservationWriter.confirm] 이미 확정된 예약: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS)
}
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" }
}
fun validateIsReservedByMemberAndPending(reservation: ReservationEntity, requesterId: Long) {
if (reservation.member.id != requesterId) {
log.error { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} requesterId=$requesterId" }
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
if (reservation.status != ReservationStatus.PENDING) {
log.warn { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약 상태가 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING)
}
}
fun validateIsPending(reservation: ReservationEntity) {
log.debug { "[ReservationValidator.validateIsPending] 시작: reservationId=${reservation.id}, status=${reservation.status}" }
if (reservation.status != ReservationStatus.PENDING) {
log.warn { "[ReservationValidator.validateIsPending] 예약 상태가 결제 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING)
}
log.debug { "[ReservationValidator.validateIsPending] 완료: reservationId=${reservation.id}, status=${reservation.status}" }
}
fun validateModifyAuthority(reservation: ReservationEntity, memberId: Long) {
log.debug { "[ReservationValidator.validateModifyAuthority] 시작: reservationId=${reservation.id}, memberId=$memberId" }
if (reservation.member.id != memberId) {
log.error { "[ReservationValidator.validateModifyAuthority] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} memberId=$memberId" }
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
log.debug { "[ReservationValidator.validateModifyAuthority] 완료: reservationId=${reservation.id}, memberId=$memberId" }
}
}

View File

@ -1,131 +0,0 @@
package roomescape.reservation.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.member.implement.MemberFinder
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.implement.ThemeFinder
import roomescape.time.implement.TimeFinder
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationWriter(
private val reservationValidator: ReservationValidator,
private val reservationRepository: ReservationRepository,
private val memberFinder: MemberFinder,
private val timeFinder: TimeFinder,
private val themeFinder: ThemeFinder,
private val tsidFactory: TsidFactory,
) {
fun create(
date: LocalDate,
timeId: Long,
themeId: Long,
memberId: Long,
status: ReservationStatus,
requesterId: Long
): ReservationEntity {
log.debug {
"[ReservationWriter.create] 시작: " +
"date=${date}, timeId=${timeId}, themeId=${themeId}, memberId=${memberId}, status=${status}"
}
val time = timeFinder.findById(timeId).also {
reservationValidator.validateIsPast(date, it.startAt)
}
val theme = themeFinder.findById(themeId)
val member = memberFinder.findById(memberId).also {
if (status == ReservationStatus.WAITING) {
reservationValidator.validateMemberAlreadyReserve(themeId, timeId, date, it.id!!)
} else {
reservationValidator.validateIsAlreadyExists(date, time, theme)
}
if (memberId != requesterId) {
val requester = memberFinder.findById(requesterId)
reservationValidator.validateCreateAuthority(requester)
}
}
val reservation = ReservationEntity(
_id = tsidFactory.next(),
date = date,
time = time,
theme = theme,
member = member,
status = status
)
return reservationRepository.save(reservation)
.also { log.debug { "[ReservationWriter.create] 완료: reservationId=${it.id}, status=${it.status}" } }
}
fun deleteWaiting(reservation: ReservationEntity, requesterId: Long) {
log.debug { "[ReservationWriter.deleteWaiting] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" }
reservationValidator.validateIsWaiting(reservation)
delete(reservation, requesterId)
.also { log.debug { "[ReservationWriter.deleteWaiting] 완료: reservationId=${reservation.id}, status=${reservation.status}" } }
}
fun deleteConfirmed(reservation: ReservationEntity, requesterId: Long) {
log.debug { "[ReservationWriter.deleteConfirmed] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" }
delete(reservation, requesterId)
.also { log.debug { "[ReservationWriter.deleteConfirmed] 완료: reservationId=${reservation.id}, status=${reservation.status}" } }
}
private fun delete(reservation: ReservationEntity, requesterId: Long) {
memberFinder.findById(requesterId)
.also { reservationValidator.validateDeleteAuthority(reservation, requester = it) }
reservationRepository.delete(reservation)
}
fun confirm(reservationId: Long) {
log.debug { "[ReservationWriter.confirm] 대기 여부 확인 시작: reservationId=$reservationId" }
reservationValidator.validateAlreadyConfirmed(reservationId)
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" }
}
fun modifyStatusToCanceledByUser(reservation: ReservationEntity, requesterId: Long) {
log.debug { "[ReservationWriter.cancel] 예약 취소 시작: reservationId=${reservation.id}, requesterId=$requesterId" }
memberFinder.findById(requesterId)
.also { reservationValidator.validateDeleteAuthority(reservation, requester = it) }
reservation.cancelByUser().also {
log.debug { "[ReservationWriter.cancel] 예약 취소 완료: reservationId=${reservation.id}" }
}
}
fun modifyStatusFromPendingToConfirmed(reservationId: Long, memberId: Long): ReservationEntity {
log.debug { "[ReservationWriter.confirmPendingReservation] 시작: reservationId=$reservationId, memberId=$memberId" }
return reservationRepository.findByIdOrNull(reservationId)?.also {
reservationValidator.validateIsPending(it)
reservationValidator.validateModifyAuthority(it, memberId)
it.confirm()
log.debug { "[ReservationWriter.confirmPendingReservation] 완료: reservationId=${it.id}, status=${it.status}" }
} ?: run {
log.warn { "[ReservationWriter.confirmPendingReservation] 예약을 찾을 수 없음: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
}

View File

@ -0,0 +1,27 @@
package roomescape.reservation.infrastructure.persistence
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import roomescape.common.entity.BaseEntityV2
import java.time.LocalDateTime
@Entity
@Table(name = "canceled_reservation")
class CanceledReservationEntity(
id: Long,
val reservationId: Long,
val canceledBy: Long,
val cancelReason: String,
val canceledAt: LocalDateTime,
@Enumerated(value = EnumType.STRING)
val status: CanceledReservationStatus,
) : BaseEntityV2(id)
enum class CanceledReservationStatus {
PROCESSING, FAILED, COMPLETED
}

View File

@ -0,0 +1,5 @@
package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface CanceledReservationRepository : JpaRepository<CanceledReservationEntity, Long>

View File

@ -1,68 +1,35 @@
package roomescape.reservation.infrastructure.persistence package roomescape.reservation.infrastructure.persistence
import com.fasterxml.jackson.annotation.JsonIgnore import jakarta.persistence.Entity
import jakarta.persistence.* import jakarta.persistence.EnumType
import roomescape.common.entity.BaseEntity import jakarta.persistence.Enumerated
import roomescape.member.infrastructure.persistence.MemberEntity import jakarta.persistence.Table
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.common.entity.AuditingBaseEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
@Entity @Entity
@Table(name = "reservations") @Table(name = "reservation")
class ReservationEntity( class ReservationEntity(
@Id id: Long,
@Column(name = "reservation_id")
private var _id: Long?,
@Column(name = "date", nullable = false) val memberId: Long,
var date: LocalDate, val scheduleId: Long,
val reserverName: String,
@ManyToOne(fetch = FetchType.LAZY) val reserverContact: String,
@JoinColumn(name = "time_id", nullable = false) val participantCount: Short,
var time: TimeEntity, val requirement: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "theme_id", nullable = false)
var theme: ThemeEntity,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: MemberEntity,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
var status: ReservationStatus, var status: ReservationStatus,
): BaseEntity() { ) : AuditingBaseEntity(id) {
override fun getId(): Long? = _id
@JsonIgnore
fun isWaiting(): Boolean = status == ReservationStatus.WAITING
@JsonIgnore
fun isReservedBy(memberId: Long): Boolean {
return this.member.id == memberId
}
fun cancelByUser() {
this.status = ReservationStatus.CANCELED_BY_USER
}
fun confirm() { fun confirm() {
this.status = ReservationStatus.CONFIRMED this.status = ReservationStatus.CONFIRMED
} }
fun cancel() {
this.status = ReservationStatus.CANCELED
}
} }
enum class ReservationStatus { enum class ReservationStatus {
CONFIRMED, PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED
CONFIRMED_PAYMENT_REQUIRED,
PENDING,
WAITING,
CANCELED_BY_USER,
AUTOMATICALLY_CANCELED,
;
companion object {
fun confirmedStatus(): Array<ReservationStatus> = arrayOf(CONFIRMED, CONFIRMED_PAYMENT_REQUIRED)
}
} }

View File

@ -1,75 +1,8 @@
package roomescape.reservation.infrastructure.persistence package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import roomescape.reservation.web.MyReservationRetrieveResponse
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
interface ReservationRepository interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
: JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> {
fun findAllByTime(time: TimeEntity): List<ReservationEntity>
fun existsByTime(time: TimeEntity): Boolean
fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity> fun findAllByMemberId(memberId: Long): List<ReservationEntity>
@Modifying
@Query(
"""
UPDATE ReservationEntity r
SET r.status = :status
WHERE r._id = :_id
"""
)
fun updateStatusByReservationId(
@Param(value = "_id") reservationId: Long,
@Param(value = "status") statusForChange: ReservationStatus
): Int
@Query(
"""
SELECT EXISTS (
SELECT 1
FROM ReservationEntity r2
WHERE r2._id = :_id
AND EXISTS (
SELECT 1 FROM ReservationEntity r
WHERE r.theme._id = r2.theme._id
AND r.time._id = r2.time._id
AND r.date = r2.date
AND r.status != 'WAITING'
)
)
"""
)
fun isExistConfirmedReservation(@Param("_id") reservationId: Long): Boolean
@Query(
"""
SELECT new roomescape.reservation.web.MyReservationRetrieveResponse(
r._id,
t.name,
r.date,
r.time.startAt,
r.status,
(SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2._id < r._id),
p.paymentKey,
p.totalAmount
)
FROM ReservationEntity r
JOIN r.theme t
LEFT JOIN PaymentEntity p
ON p.reservation = r
WHERE r.member._id = :memberId
"""
)
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse>
fun findAllByDateAndTheme(date: LocalDate, theme: ThemeEntity): List<ReservationEntity>
fun findAllByMember_Id(memberId: Long): List<ReservationEntity>
} }

View File

@ -1,80 +0,0 @@
package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.domain.Specification
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
class ReservationSearchSpecification(
private var spec: Specification<ReservationEntity> = Specification { _, _, _ -> null }
) {
fun sameThemeId(themeId: Long?): ReservationSearchSpecification = andIfNotNull(themeId?.let {
Specification { root, _, cb ->
cb.equal(root.get<ThemeEntity>("theme").get<Long>("id"), themeId)
}
})
fun sameMemberId(memberId: Long?): ReservationSearchSpecification = andIfNotNull(memberId?.let {
Specification { root, _, cb ->
cb.equal(root.get<MemberEntity>("member").get<Long>("id"), memberId)
}
})
fun sameTimeId(timeId: Long?): ReservationSearchSpecification = andIfNotNull(timeId?.let {
Specification { root, _, cb ->
cb.equal(root.get<TimeEntity>("time").get<Long>("id"), timeId)
}
})
fun sameDate(date: LocalDate?): ReservationSearchSpecification = andIfNotNull(date?.let {
Specification { root, _, cb ->
cb.equal(root.get<LocalDate>("date"), date)
}
})
fun status(vararg statuses: ReservationStatus) = andIfNotNull { root, _, cb ->
root.get<ReservationStatus>("status").`in`(statuses.toList())
}
fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.or(
cb.equal(
root.get<ReservationStatus>("status"),
ReservationStatus.CONFIRMED
),
cb.equal(
root.get<ReservationStatus>("status"),
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
)
}
fun waiting(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.equal(
root.get<ReservationStatus>("status"),
ReservationStatus.WAITING
)
}
fun dateStartFrom(dateFrom: LocalDate?): ReservationSearchSpecification = andIfNotNull(dateFrom?.let {
Specification { root, _, cb ->
cb.greaterThanOrEqualTo(root.get("date"), dateFrom)
}
})
fun dateEndAt(dateTo: LocalDate?): ReservationSearchSpecification = andIfNotNull(dateTo?.let {
Specification { root, _, cb ->
cb.lessThanOrEqualTo(root.get("date"), dateTo)
}
})
fun build(): Specification<ReservationEntity> {
return this.spec
}
private fun andIfNotNull(condition: Specification<ReservationEntity>?): ReservationSearchSpecification {
condition?.let { this.spec = this.spec.and(condition) }
return this
}
}

View File

@ -1,35 +0,0 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.Parameter
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.MyReservationFindService
import roomescape.reservation.docs.MyReservationAPI
@RestController
class MyReservationController(
private val reservationFindService: MyReservationFindService
) : MyReservationAPI {
@GetMapping("/v2/reservations")
override fun findAllMyReservations(
@MemberId @Parameter(hidden=true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>> {
val response = reservationFindService.findReservationsByMemberId(memberId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/v2/reservations/{id}/details")
override fun showReservationDetails(
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>> {
val response = reservationFindService.showReservationDetails(reservationId)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -1,196 +0,0 @@
package roomescape.reservation.web
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.PaymentDetailResponse.BankTransferDetailResponse
import roomescape.reservation.web.PaymentDetailResponse.CardDetailResponse
import roomescape.reservation.web.PaymentDetailResponse.EasyPayPrepaidDetailResponse
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import kotlin.Int
data class ReservationSummaryRetrieveResponse(
val id: Long,
val themeName: String,
val date: LocalDate,
val startAt: LocalTime,
val status: ReservationStatus
)
fun ReservationEntity.toReservationSummaryRetrieveResponse(): ReservationSummaryRetrieveResponse {
return ReservationSummaryRetrieveResponse(
id = this.id!!,
themeName = this.theme.name,
date = this.date,
startAt = this.time.startAt,
status = this.status
)
}
data class ReservationSummaryRetrieveListResponse(
val reservations: List<ReservationSummaryRetrieveResponse>
)
fun List<ReservationEntity>.toSummaryListResponse(): ReservationSummaryRetrieveListResponse {
return ReservationSummaryRetrieveListResponse(
reservations = this.map { it.toReservationSummaryRetrieveResponse() }
)
}
data class ReservationDetailRetrieveResponse(
val id: Long,
val user: UserDetailRetrieveResponse,
val themeName: String,
val date: LocalDate,
val startAt: LocalTime,
val applicationDateTime: LocalDateTime,
val payment: PaymentRetrieveResponse,
val cancellation: PaymentCancelDetailResponse? = null
)
data class UserDetailRetrieveResponse(
val id: Long,
val name: String,
val email: String
)
fun MemberEntity.toUserDetailRetrieveResponse(): UserDetailRetrieveResponse {
return UserDetailRetrieveResponse(
id = this.id!!,
name = this.name,
email = this.email
)
}
fun ReservationEntity.toReservationDetailRetrieveResponse(
payment: PaymentRetrieveResponse,
cancellation: PaymentCancelDetailResponse? = null
): ReservationDetailRetrieveResponse {
return ReservationDetailRetrieveResponse(
id = this.id!!,
user = this.member.toUserDetailRetrieveResponse(),
themeName = this.theme.name,
date = this.date,
startAt = this.time.startAt,
applicationDateTime = this.createdAt!!,
payment = payment,
cancellation = cancellation,
)
}
data class PaymentRetrieveResponse(
val orderId: String,
val totalAmount: Int,
val method: String,
val status: PaymentStatus,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val detail: PaymentDetailResponse,
)
fun PaymentEntityV2.toRetrieveResponse(detail: PaymentDetailResponse): PaymentRetrieveResponse {
return PaymentRetrieveResponse(
orderId = this.orderId,
totalAmount = this.totalAmount,
method = this.method.koreanName,
status = this.status,
requestedAt = this.requestedAt,
approvedAt = this.approvedAt,
detail = detail
)
}
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: LocalDateTime,
val cancellationApprovedAt: OffsetDateTime?,
val cancelReason: String,
val canceledBy: Long,
)
fun CanceledPaymentEntityV2.toCancelDetailResponse(): PaymentCancelDetailResponse {
return PaymentCancelDetailResponse(
cancellationRequestedAt = this.requestedAt,
cancellationApprovedAt = this.canceledAt,
cancelReason = this.cancelReason,
canceledBy = this.canceledBy
)
}

View File

@ -6,170 +6,59 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.infrastructure.client.PaymentApproveRequest import roomescape.reservation.business.ReservationService
import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.client.TossPaymentClient
import roomescape.payment.web.PaymentCancelRequest
import roomescape.reservation.business.ReservationWriteService
import roomescape.reservation.business.ReservationFindService
import roomescape.reservation.business.ReservationWithPaymentService
import roomescape.reservation.docs.ReservationAPI import roomescape.reservation.docs.ReservationAPI
import java.net.URI
import java.time.LocalDate
@RestController @RestController
class ReservationController( class ReservationController(
private val reservationWithPaymentService: ReservationWithPaymentService, private val reservationService: ReservationService
private val reservationFindService: ReservationFindService,
private val reservationWriteService: ReservationWriteService,
private val paymentClient: TossPaymentClient
) : ReservationAPI { ) : ReservationAPI {
@GetMapping("/reservations")
override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { @PostMapping("/reservations/pending")
val response: ReservationRetrieveListResponse = reservationFindService.findReservations() override fun createPendingReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody request: PendingReservationCreateRequest
): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>> {
val response = reservationService.createPendingReservation(memberId, request)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@GetMapping("/reservations-mine") @PostMapping("/reservations/{id}/confirm")
override fun findReservationsByMemberId( override fun confirmReservation(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.confirmReservation(id)
return ResponseEntity.ok().body(CommonApiResponse())
}
@PostMapping("/reservations/{reservationId}/cancel")
override fun cancelReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable reservationId: Long,
@Valid @RequestBody request: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.cancelReservation(memberId, reservationId, request)
return ResponseEntity.ok().body(CommonApiResponse())
}
@GetMapping("/reservations/summary")
override fun findSummaryByMemberId(
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>> {
val response: MyReservationRetrieveListResponse = reservationFindService.findReservationsByMemberId(memberId) val response = reservationService.findSummaryByMemberId(memberId)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@GetMapping("/reservations/search") @GetMapping("/reservations/{id}/detail")
override fun searchReservations( override fun findDetailById(
@RequestParam(required = false) themeId: Long?, @PathVariable("id") id: Long
@RequestParam(required = false) memberId: Long?, ): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>> {
@RequestParam(required = false) dateFrom: LocalDate?, val response = reservationService.findDetailById(id)
@RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationFindService.searchReservations(
themeId,
memberId,
dateFrom,
dateTo
)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@DeleteMapping("/reservations/{id}")
override fun cancelReservationByAdmin(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
reservationWriteService.deleteReservation(reservationId, memberId)
return ResponseEntity.noContent().build()
}
val paymentCancelRequest = reservationWithPaymentService.deleteReservationAndPayment(reservationId, memberId)
val paymentCancelResponse = paymentClient.cancel(paymentCancelRequest)
reservationWithPaymentService.updateCanceledTime(
paymentCancelRequest.paymentKey,
paymentCancelResponse.canceledAt
)
return ResponseEntity.noContent().build()
}
@PostMapping("/reservations")
override fun createReservationWithPayment(
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
val paymentRequest: PaymentApproveRequest = reservationCreateWithPaymentRequest.toPaymentApproveRequest()
val paymentResponse: PaymentApproveResponse = paymentClient.confirm(paymentRequest)
try {
val response: ReservationCreateResponse =
reservationWithPaymentService.createReservationAndPayment(
reservationCreateWithPaymentRequest,
paymentResponse,
memberId
)
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response))
} catch (e: Exception) {
val cancelRequest = PaymentCancelRequest(
paymentRequest.paymentKey,
paymentRequest.amount,
e.message!!
)
val paymentCancelResponse = paymentClient.cancel(cancelRequest)
reservationWithPaymentService.createCanceledPayment(
paymentCancelResponse,
paymentResponse.approvedAt,
paymentRequest.paymentKey
)
throw e
}
}
@PostMapping("/reservations/admin")
override fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
val response: ReservationCreateResponse =
reservationWriteService.createReservationByAdmin(adminReservationRequest, memberId)
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response))
}
@GetMapping("/reservations/waiting")
override fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationFindService.findAllWaiting()
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/reservations/waiting")
override fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
val response: ReservationCreateResponse = reservationWriteService.createWaiting(
waitingCreateRequest,
memberId
)
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response))
}
@DeleteMapping("/reservations/waiting/{id}")
override fun cancelWaitingByMember(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
reservationWriteService.deleteWaiting(reservationId, memberId)
return ResponseEntity.noContent().build()
}
@PostMapping("/reservations/waiting/{id}/confirm")
override fun confirmWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
reservationWriteService.confirmWaiting(reservationId, memberId)
return ResponseEntity.ok().build()
}
@PostMapping("/reservations/waiting/{id}/reject")
override fun rejectWaiting(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> {
reservationWriteService.deleteWaiting(reservationId, memberId)
return ResponseEntity.noContent().build()
}
} }

View File

@ -0,0 +1,70 @@
package roomescape.reservation.web
import jakarta.validation.constraints.NotEmpty
import roomescape.member.web.MemberSummaryRetrieveResponse
import roomescape.payment.web.PaymentRetrieveResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import java.time.LocalDate
import java.time.LocalDateTime
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, memberId: Long) = ReservationEntity(
id = id,
memberId = memberId,
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 ReservationSummaryRetrieveResponse(
val id: Long,
val themeName: String,
val date: LocalDate,
val startAt: LocalTime,
val status: ReservationStatus
)
data class ReservationSummaryRetrieveListResponse(
val reservations: List<ReservationSummaryRetrieveResponse>
)
data class ReservationDetailRetrieveResponse(
val id: Long,
val member: MemberSummaryRetrieveResponse,
val applicationDateTime: LocalDateTime,
val payment: PaymentRetrieveResponse,
)
fun ReservationEntity.toReservationDetailRetrieveResponse(
member: MemberSummaryRetrieveResponse,
payment: PaymentRetrieveResponse,
): ReservationDetailRetrieveResponse {
return ReservationDetailRetrieveResponse(
id = this.id,
member = member,
applicationDateTime = this.createdAt,
payment = payment,
)
}
data class ReservationCancelRequest(
val cancelReason: String
)

View File

@ -1,40 +0,0 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.payment.infrastructure.client.PaymentApproveRequest
import java.time.LocalDate
data class AdminReservationCreateRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long,
val memberId: Long
)
data class ReservationCreateWithPaymentRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long,
@Schema(description = "결제 위젯을 통해 받은 결제 키")
val paymentKey: String,
@Schema(description = "결제 위젯을 통해 받은 주문번호.")
val orderId: String,
@Schema(description = "결제 위젯을 통해 받은 결제 금액")
val amount: Long,
@Schema(description = "결제 타입", example = "NORMAL")
val paymentType: String
)
fun ReservationCreateWithPaymentRequest.toPaymentApproveRequest(): PaymentApproveRequest = PaymentApproveRequest(
paymentKey, orderId, amount, paymentType
)
data class WaitingCreateRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long
)

View File

@ -1,93 +0,0 @@
package roomescape.reservation.web
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.member.web.MemberRetrieveResponse
import roomescape.member.web.toRetrieveResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.web.ThemeRetrieveResponse
import roomescape.theme.web.toRetrieveResponse
import roomescape.time.web.TimeCreateResponse
import roomescape.time.web.toCreateResponse
import java.time.LocalDate
import java.time.LocalTime
data class ReservationCreateResponse(
val id: Long,
val date: LocalDate,
@JsonProperty("member")
val member: MemberRetrieveResponse,
@JsonProperty("time")
val time: TimeCreateResponse,
@JsonProperty("theme")
val theme: ThemeRetrieveResponse,
val status: ReservationStatus
)
fun ReservationEntity.toCreateResponse() = ReservationCreateResponse(
id = this.id!!,
date = this.date,
member = this.member.toRetrieveResponse(),
time = this.time.toCreateResponse(),
theme = this.theme.toRetrieveResponse(),
status = this.status
)
data class MyReservationRetrieveResponse(
val id: Long,
val themeName: String,
val date: LocalDate,
val time: LocalTime,
val status: ReservationStatus,
@Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.")
val rank: Long,
@Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.")
val paymentKey: String?,
@Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.")
val amount: Long?
)
data class MyReservationRetrieveListResponse(
@Schema(description = "현재 로그인한 회원의 예약 및 대기 목록")
val reservations: List<MyReservationRetrieveResponse>
)
fun List<MyReservationRetrieveResponse>.toRetrieveListResponse() = MyReservationRetrieveListResponse(this)
data class ReservationRetrieveResponse(
val id: Long,
val date: LocalDate,
@JsonProperty("member")
val member: MemberRetrieveResponse,
@JsonProperty("time")
val time: TimeCreateResponse,
@JsonProperty("theme")
val theme: ThemeRetrieveResponse,
val status: ReservationStatus
)
fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = ReservationRetrieveResponse(
id = this.id!!,
date = this.date,
member = this.member.toRetrieveResponse(),
time = this.time.toCreateResponse(),
theme = this.theme.toRetrieveResponse(),
status = this.status
)
data class ReservationRetrieveListResponse(
val reservations: List<ReservationRetrieveResponse>
)
fun List<ReservationEntity>.toRetrieveListResponse()= ReservationRetrieveListResponse(
this.map { it.toRetrieveResponse() }
)

View File

@ -1,60 +0,0 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.ReservationWithPaymentService
import roomescape.reservation.business.ReservationWithPaymentServiceV2
import roomescape.reservation.docs.ReservationWithPaymentAPI
@RestController
class ReservationWithPaymentController(
private val reservationWithPaymentService: ReservationWithPaymentServiceV2
) : ReservationWithPaymentAPI {
@PostMapping("/v2/reservations")
override fun createPendingReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateRequest
): ResponseEntity<CommonApiResponse<ReservationCreateResponseV2>> {
val response = reservationWithPaymentService.createPendingReservation(
memberId = memberId,
request = reservationCreateWithPaymentRequest
)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/v2/reservations/{id}/pay")
override fun createPaymentAndConfirmReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody request: ReservationPaymentRequest,
): ResponseEntity<CommonApiResponse<ReservationPaymentResponse>> {
val response = reservationWithPaymentService.payReservation(
memberId = memberId,
reservationId = reservationId,
request = request
)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/v2/reservations/{id}/cancel")
override fun cancelReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody cancelRequest: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> {
reservationWithPaymentService.cancelReservation(memberId, reservationId, cancelRequest)
return ResponseEntity.noContent().build()
}
}

View File

@ -1,57 +0,0 @@
package roomescape.reservation.web
import roomescape.payment.infrastructure.client.v2.PaymentConfirmRequest
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
data class ReservationCreateRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long,
)
data class ReservationCreateResponseV2(
val reservationId: Long,
val memberEmail: String,
val date: LocalDate,
val startAt: LocalTime,
val themeName: String
)
fun ReservationEntity.toCreateResponseV2() = ReservationCreateResponseV2(
reservationId = this.id!!,
memberEmail = this.member.email,
date = this.date,
startAt = this.time.startAt,
themeName = this.theme.name
)
data class ReservationPaymentRequest(
val paymentKey: String,
val orderId: String,
val amount: Int,
val paymentType: PaymentType
)
fun ReservationPaymentRequest.toPaymentConfirmRequest() = PaymentConfirmRequest(
paymentKey = this.paymentKey,
amount = this.amount,
orderId = this.orderId,
)
data class ReservationPaymentResponse(
val reservationId: Long,
val reservationStatus: ReservationStatus,
val paymentId: Long,
val paymentStatus: PaymentStatus,
)
data class ReservationCancelRequest(
val cancelReason: String,
val requestedAt: LocalDateTime = LocalDateTime.now()
)

View File

@ -30,8 +30,7 @@ class ScheduleService(
fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse { fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse {
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" } log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" }
return scheduleRepository.findAllByDate(date) return AvailableThemeIdListResponse(scheduleRepository.findAllUniqueThemeIdByDate(date))
.toThemeIdListResponse()
.also { .also {
log.info { "[ScheduleService.findThemesByDate] date=${date}${it.themeIds.size}개 테마 조회 완료" } log.info { "[ScheduleService.findThemesByDate] date=${date}${it.themeIds.size}개 테마 조회 완료" }
} }
@ -54,8 +53,8 @@ class ScheduleService(
val schedule: ScheduleEntity = findOrThrow(id) val schedule: ScheduleEntity = findOrThrow(id)
val createdBy = memberService.findById(schedule.createdBy).name val createdBy = memberService.findSummaryById(schedule.createdBy).name
val updatedBy = memberService.findById(schedule.updatedBy).name val updatedBy = memberService.findSummaryById(schedule.updatedBy).name
return schedule.toDetailRetrieveResponse(createdBy, updatedBy) return schedule.toDetailRetrieveResponse(createdBy, updatedBy)
.also { .also {
@ -63,6 +62,16 @@ class ScheduleService(
} }
} }
@Transactional(readOnly = true)
fun findSummaryById(id: Long): ScheduleSummaryResponse {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" }
return findOrThrow(id).toSummaryResponse()
.also {
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" }
}
}
@Transactional @Transactional
fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse { fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse {
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" } log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
@ -83,6 +92,18 @@ class ScheduleService(
} }
} }
@Transactional
fun holdSchedule(id: Long) {
val schedule: ScheduleEntity = findOrThrow(id)
if (schedule.status == ScheduleStatus.AVAILABLE) {
schedule.hold()
return
}
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
}
@Transactional @Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }

View File

@ -23,7 +23,7 @@ class ScheduleValidator(
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 { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE) throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
} }

View File

@ -38,6 +38,19 @@ interface ScheduleAPI {
@RequestParam("themeId") themeId: Long @RequestParam("themeId") themeId: Long
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>> ): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>>
@LoginRequired
@Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "200",
description = "일정을 Hold 상태로 변경하여 중복 예약 방지",
useReturnTypeSchema = true
)
)
fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>>
@Admin @Admin
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))

View File

@ -12,4 +12,5 @@ enum class ScheduleErrorCode(
SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."), SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."),
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."), PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."), SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."),
SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요.")
} }

View File

@ -1,10 +1,6 @@
package roomescape.schedule.infrastructure.persistence package roomescape.schedule.infrastructure.persistence
import jakarta.persistence.Entity import jakarta.persistence.*
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import roomescape.common.entity.AuditingBaseEntity import roomescape.common.entity.AuditingBaseEntity
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -29,8 +25,12 @@ class ScheduleEntity(
time?.let { this.time = it } time?.let { this.time = it }
status?.let { this.status = it } status?.let { this.status = it }
} }
fun hold() {
this.status = ScheduleStatus.HOLD
}
} }
enum class ScheduleStatus { enum class ScheduleStatus {
AVAILABLE, PENDING, RESERVED, BLOCKED AVAILABLE, HOLD, RESERVED, BLOCKED
} }

View File

@ -1,6 +1,7 @@
package roomescape.schedule.infrastructure.persistence package roomescape.schedule.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -11,4 +12,13 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity> fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity>
fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean
@Query(
"""
SELECT DISTINCT s.themeId
FROM ScheduleEntity s
WHERE s.date = :date
"""
)
fun findAllUniqueThemeIdByDate(date: LocalDate): List<Long>
} }

View File

@ -50,6 +50,15 @@ class ScheduleController(
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@PatchMapping("/schedules/{id}/hold")
override fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.holdSchedule(id)
return ResponseEntity.ok(CommonApiResponse())
}
@PatchMapping("/schedules/{id}") @PatchMapping("/schedules/{id}")
override fun updateSchedule( override fun updateSchedule(
@PathVariable("id") id: Long, @PathVariable("id") id: Long,

View File

@ -10,8 +10,6 @@ data class AvailableThemeIdListResponse(
val themeIds: List<Long> val themeIds: List<Long>
) )
fun List<ScheduleEntity>.toThemeIdListResponse() = AvailableThemeIdListResponse(this.map { it.themeId })
data class ScheduleRetrieveResponse( data class ScheduleRetrieveResponse(
val id: Long, val id: Long,
val time: LocalTime, val time: LocalTime,
@ -66,3 +64,17 @@ fun ScheduleEntity.toDetailRetrieveResponse(createdBy: String, updatedBy: String
updatedAt = this.updatedAt, updatedAt = this.updatedAt,
updatedBy = updatedBy updatedBy = updatedBy
) )
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
)

View File

@ -1,67 +1,150 @@
package roomescape.theme.business package roomescape.theme.business
import com.github.f4b6a3.tsid.TsidFactory
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 roomescape.theme.implement.ThemeFinder import roomescape.common.config.next
import roomescape.theme.implement.ThemeWriter import roomescape.member.business.MemberService
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.* import roomescape.theme.web.*
import java.time.LocalDate
private val log = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class ThemeService( class ThemeService(
private val themeFinder: ThemeFinder, private val themeRepository: ThemeRepository,
private val themeWriter: ThemeWriter, private val tsidFactory: TsidFactory,
private val memberService: MemberService,
private val themeValidator: ThemeValidator
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(id: Long): ThemeEntity { fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeSummaryListResponse {
log.debug { "[ThemeService.findById] 시작: themeId=$id" } log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
val result: MutableList<ThemeEntity> = mutableListOf()
return themeFinder.findById(id) for (id in request.themeIds) {
.also { log.info { "[ThemeService.findById] 완료: themeId=$id, name=${it.name}" } } val theme: ThemeEntity? = themeRepository.findByIdOrNull(id)
if (theme == null) {
log.warn { "[ThemeService.findThemesByIds] id=${id} 인 테마 조회 실패" }
continue
}
result.add(theme)
}
return result.toRetrieveListResponse().also {
log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" }
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemes(): ThemeRetrieveListResponse { fun findThemesForReservation(): ThemeSummaryListResponse {
log.debug { "[ThemeService.findThemes] 시작" } log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
return themeFinder.findAll() return themeRepository.findOpenedThemes()
.toRetrieveListResponse() .toRetrieveListResponse()
.also { log.info { "[ThemeService.findThemes] 완료. ${it.themes.size}개 반환" } } .also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse { fun findAdminThemes(): AdminThemeSummaryRetrieveListResponse {
log.debug { "[ThemeService.findMostReservedThemes] 시작: count=$count" } log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
val today = LocalDate.now() return themeRepository.findAll()
val startFrom = today.minusDays(7) .toAdminThemeSummaryListResponse()
val endAt = today.minusDays(1) .also { log.info { "[ThemeService.findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
}
return themeFinder.findMostReservedThemes(count, startFrom, endAt) @Transactional(readOnly = true)
.toRetrieveListResponse() fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse {
.also { log.info { "[ThemeService.findMostReservedThemes] ${it.themes.size}개 반환" } } log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id)
val createdBy = memberService.findSummaryById(theme.createdBy).name
val updatedBy = memberService.findSummaryById(theme.updatedBy).name
return theme.toAdminThemeDetailResponse(createdBy, updatedBy)
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
}
@Transactional(readOnly = true)
fun findSummaryById(id: Long): ThemeSummaryResponse {
log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" }
return findOrThrow(id).toSummaryResponse()
.also { log.info { "[ThemeService.findById] 테마 조회 완료: id=$id" } }
} }
@Transactional @Transactional
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { fun createTheme(request: ThemeCreateRequest): ThemeCreateResponseV2 {
log.debug { "[ThemeService.createTheme] 시작: name=${request.name}" } log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
return themeWriter.create(request.name, request.description, request.thumbnail) themeValidator.validateCanCreate(request)
.toCreateResponse()
.also { log.info { "[ThemeService.createTheme] 테마 생성 완료: name=${it.name} themeId=${it.id}" } } val theme: ThemeEntity = themeRepository.save(
request.toEntity(tsidFactory.next())
)
return ThemeCreateResponseV2(theme.id).also {
log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
}
} }
@Transactional @Transactional
fun deleteTheme(id: Long) { fun deleteTheme(id: Long) {
log.debug { "[ThemeService.deleteTheme] 시작: themeId=$id" } log.info { "[ThemeService.deleteTheme] 테마 삭제 시작: id=${id}" }
val theme: ThemeEntity = themeFinder.findById(id) val theme: ThemeEntity = findOrThrow(id)
themeWriter.delete(theme) themeRepository.delete(theme).also {
.also { log.info { "[ThemeService.deleteTheme] 완료: themeId=$id, name=${theme.name}" } } log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
}
}
@Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.info { "[ThemeService.updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[ThemeService.updateTheme] 테마 변경 사항 없음: id=${id}" }
return
}
themeValidator.validateCanUpdate(request)
val theme: ThemeEntity = findOrThrow(id)
theme.modifyIfNotNull(
request.name,
request.description,
request.thumbnailUrl,
request.difficulty,
request.price,
request.minParticipants,
request.maxParticipants,
request.availableMinutes,
request.expectedMinutesFrom,
request.expectedMinutesTo,
request.isOpen,
).also {
log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" }
}
}
private fun findOrThrow(id: Long): ThemeEntity {
log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.info { "[ThemeService.findOrThrow] 테마 조회 완료: id=$id" } }
?: run {
log.warn { "[ThemeService.updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
} }
} }

View File

@ -1,133 +0,0 @@
package roomescape.theme.business
import com.github.f4b6a3.tsid.TsidFactory
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 roomescape.common.config.next
import roomescape.member.business.MemberService
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2
import roomescape.theme.web.*
private val log: KLogger = KotlinLogging.logger {}
@Service
class ThemeServiceV2(
private val themeRepository: ThemeRepositoryV2,
private val tsidFactory: TsidFactory,
private val memberService: MemberService,
private val themeValidator: ThemeValidatorV2
) {
@Transactional(readOnly = true)
fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeRetrieveListResponseV2 {
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
return request.themeIds
.map { findOrThrow(it) }
.toRetrieveListResponse()
.also { log.info { "[ThemeService.findThemesByIds] ${it.themes.size}개 테마 조회 완료" } }
}
@Transactional(readOnly = true)
fun findThemesForReservation(): ThemeRetrieveListResponseV2 {
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findOpenedThemes()
.toRetrieveListResponse()
.also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } }
}
@Transactional(readOnly = true)
fun findAdminThemes(): AdminThemeSummaryRetrieveListResponse {
log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findAll()
.toAdminThemeSummaryListResponse()
.also { log.info { "[ThemeService.findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
}
@Transactional(readOnly = true)
fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse {
log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme: ThemeEntityV2 = findOrThrow(id)
val createdBy = memberService.findById(theme.createdBy).name
val updatedBy = memberService.findById(theme.updatedBy).name
return theme.toAdminThemeDetailResponse(createdBy, updatedBy)
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
}
@Transactional
fun createTheme(request: ThemeCreateRequestV2): ThemeCreateResponseV2 {
log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request)
val theme: ThemeEntityV2 = themeRepository.save(
request.toEntity(tsidFactory.next())
)
return ThemeCreateResponseV2(theme.id).also {
log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
}
}
@Transactional
fun deleteTheme(id: Long) {
log.info { "[ThemeService.deleteTheme] 테마 삭제 시작: id=${id}" }
val theme: ThemeEntityV2 = findOrThrow(id)
themeRepository.delete(theme).also {
log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
}
}
@Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.info { "[ThemeService.updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[ThemeService.updateTheme] 테마 변경 사항 없음: id=${id}" }
return
}
themeValidator.validateCanUpdate(request)
val theme: ThemeEntityV2 = findOrThrow(id)
theme.modifyIfNotNull(
request.name,
request.description,
request.thumbnailUrl,
request.difficulty,
request.price,
request.minParticipants,
request.maxParticipants,
request.availableMinutes,
request.expectedMinutesFrom,
request.expectedMinutesTo,
request.isOpen,
).also {
log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" }
}
}
private fun findOrThrow(id: Long): ThemeEntityV2 {
log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.info { "[ThemeService.findOrThrow] 테마 조회 완료: id=$id" } }
?: run {
log.warn { "[ThemeService.updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
}
}

View File

@ -5,8 +5,8 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2 import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.ThemeCreateRequestV2 import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeUpdateRequest import roomescape.theme.web.ThemeUpdateRequest
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@ -16,8 +16,8 @@ const val MIN_PARTICIPANTS = 1
const val MIN_DURATION = 1 const val MIN_DURATION = 1
@Component @Component
class ThemeValidatorV2( class ThemeValidator(
private val themeRepository: ThemeRepositoryV2, private val themeRepository: ThemeRepository,
) { ) {
fun validateCanUpdate(request: ThemeUpdateRequest) { fun validateCanUpdate(request: ThemeUpdateRequest) {
validateProperties( validateProperties(
@ -30,7 +30,7 @@ class ThemeValidatorV2(
) )
} }
fun validateCanCreate(request: ThemeCreateRequestV2) { fun validateCanCreate(request: ThemeCreateRequest) {
if (themeRepository.existsByName(request.name)) { if (themeRepository.existsByName(request.name)) {
log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" } log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" }
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)

View File

@ -1,52 +0,0 @@
package roomescape.theme.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse
import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeCreateResponse
import roomescape.theme.web.ThemeRetrieveListResponse
import roomescape.theme.web.ThemeRetrieveResponse
@Tag(name = "5. 테마 API", description = "테마를 조회 / 추가 / 삭제할 때 사용합니다.")
interface ThemeAPI {
@LoginRequired
@Operation(summary = "모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>>
@Operation(summary = "가장 많이 예약된 테마 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findMostReservedThemes(
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int
): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>>
@Admin
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true),
)
fun createTheme(
@Valid @RequestBody request: ThemeCreateRequest,
): ResponseEntity<CommonApiResponse<ThemeCreateResponse>>
@Admin
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true),
)
fun deleteTheme(
@PathVariable id: Long
): ResponseEntity<CommonApiResponse<Unit>>
}

View File

@ -11,13 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.support.Admin import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.theme.web.AdminThemeDetailRetrieveResponse import roomescape.theme.web.*
import roomescape.theme.web.AdminThemeSummaryRetrieveListResponse
import roomescape.theme.web.ThemeCreateRequestV2
import roomescape.theme.web.ThemeCreateResponseV2
import roomescape.theme.web.ThemeListRetrieveRequest
import roomescape.theme.web.ThemeUpdateRequest
import roomescape.theme.web.ThemeRetrieveListResponseV2
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.")
interface ThemeAPIV2 { interface ThemeAPIV2 {
@ -35,7 +29,7 @@ interface ThemeAPIV2 {
@Admin @Admin
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun createTheme(@Valid @RequestBody themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>> fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
@Admin @Admin
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
@ -53,10 +47,10 @@ interface ThemeAPIV2 {
@LoginRequired @LoginRequired
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
} }

View File

@ -1,47 +0,0 @@
package roomescape.theme.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class ThemeFinder(
private val themeRepository: ThemeRepository
) {
fun findAll(): List<ThemeEntity> {
log.debug { "[ThemeFinder.findAll] 시작" }
return themeRepository.findAll()
.also { log.debug { "[TimeFinder.findAll] ${it.size}개 테마 조회 완료" } }
}
fun findById(id: Long): ThemeEntity {
log.debug { "[ThemeFinder.findById] 조회 시작: memberId=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.debug { "[ThemeFinder.findById] 조회 완료: id=$id, name=${it.name}" } }
?: run {
log.warn { "[ThemeFinder.findById] 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
}
fun findMostReservedThemes(
count: Int,
startFrom: LocalDate,
endAt: LocalDate
): List<ThemeEntity> {
log.debug { "[ThemeFinder.findMostReservedThemes] 시작. count=$count, startFrom=$startFrom, endAt=$endAt" }
return themeRepository.findPopularThemes(startFrom, endAt, count)
.also { log.debug { "[ThemeFinder.findMostReservedThemes] ${it.size} / ${count}개 테마 조회 완료" } }
}
}

View File

@ -1,43 +0,0 @@
package roomescape.theme.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class ThemeValidator(
private val themeRepository: ThemeRepository
) {
fun validateNameAlreadyExists(name: String) {
log.debug { "[ThemeValidator.validateNameAlreadyExists] 시작: name=$name" }
if (themeRepository.existsByName(name)) {
log.info { "[ThemeService.createTheme] 이름 중복: name=${name}" }
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
}
log.debug { "[ThemeValidator.validateNameAlreadyExists] 완료: name=$name" }
}
fun validateIsReserved(theme: ThemeEntity) {
val themeId: Long = theme.id ?: run {
log.warn { "[ThemeValidator.validateIsReserved] ID를 찾을 수 없음: name:${theme.name}" }
throw ThemeException(ThemeErrorCode.INVALID_REQUEST_VALUE)
}
log.debug { "[ThemeValidator.validateIsReserved] 시작: themeId=${themeId}" }
if (themeRepository.isReservedTheme(themeId)) {
log.info { "[ThemeService.deleteTheme] 예약이 있는 테마: themeId=$themeId" }
throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED)
}
log.debug { "[ThemeValidator.validateIsReserved] 완료: themeId=$themeId" }
}
}

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