Compare commits

...

41 Commits

Author SHA1 Message Date
1cb1a97703 test: 토스페이 계좌이체 클라이언트 테스트 추가 2025-09-09 09:34:03 +09:00
211edcaffd test: 예약 API 테스트 추가 2025-09-09 09:14:53 +09:00
b847e59d6f test: 결제 API 테스트 추가 2025-09-09 09:14:49 +09:00
865026aff2 style: import 정리 & 코드 포맷 정렬 2025-09-09 09:14:42 +09:00
cb14e1d6a5 feat: 테스트에서 로그인 유저 정보 조회 기능 추가 2025-09-09 09:12:25 +09:00
c057fa85e2 feat: TosspayClient 테스트 추가 2025-09-09 09:12:08 +09:00
9ce66d8074 refactor: 토스페이 응답 JSON 기록을 위한 RestClient 응답 타입 수정 2025-09-09 09:11:30 +09:00
c318f5fc7f refactor: 결제 취소시 CanceledPaymentEntity를 반환하도록 수정 2025-09-09 09:11:01 +09:00
a4334c224f refactor: 로그 가독성 향상을 위한 컨트롤러 응답 로그에 엔드포인트 추가 2025-09-09 09:08:16 +09:00
9660d5438d feat: 예약에서의 검증 케이스 추가 및 예약 확정 API Http 메서드 변경(PATCH -> POST) 2025-09-09 09:07:29 +09:00
36e846ded3 refactor: 다른 서비스에서 사용하는 일정 조회 DTO에 상태 반환 추가 2025-09-09 09:05:59 +09:00
ed618e1699 refactor: 테마 반환 DTO 이름 수정 및 테스트 추가 2025-09-09 09:05:35 +09:00
fc3c6e42b0 refactor: 실패 테스트 케이스에 사용할 INVALID PK 상수화 2025-09-09 09:04:10 +09:00
c717e1cb5b feat: 테스트 데이터 삽입 전용 클래스 추가 2025-09-09 09:02:11 +09:00
680f5a9010 refactor: 프론트엔드 예약 API 엔드포인트 수정 2025-09-09 09:00:54 +09:00
752943a9f7 chore: Tosspayment -> Tosspay 네이밍 통일 2025-09-07 22:22:07 +09:00
a4f384a242 test: 통합 테스트 전환을 위해 이전의 테스트 전체 제거 2025-09-07 22:18:45 +09:00
e4b9214d75 remove: 기능 변경 완료로 인한 기존 테마 코드 제거 2025-09-07 22:18:17 +09:00
e7f69aaee4 feat: 통합 테스트 전환 & API 기능 변경으로 인한 기존 테스트 제거 2025-09-07 22:08:40 +09:00
11fd345d5e remove: 이전 스키마 제거 2025-09-07 22:07:21 +09:00
7670e9acc1 remove: schedule 도입으로 미사용인 time 도메인 코드 전체 제거 2025-09-07 21:48:21 +09:00
7c77f50e66 remove: 예약 & 결제 도매인 기능 변경으로 인한 기존 코드 제거 2025-09-07 21:47:58 +09:00
5a9d992cb4 refactor: 예약 API 명세 및 컨트롤러 재정의 2025-09-07 21:46:59 +09:00
ddf366c587 refactor: 예약 API 변경에 따른 서비스 로직 재정의 2025-09-07 21:42:59 +09:00
9e8cb87641 feat: 예약 API 변경에 따른 DTO 재정의 2025-09-07 21:42:32 +09:00
dd34a901b3 refactor: 예약 도메인 변경에 따른 미사용 에러코드 제거 2025-09-07 21:42:09 +09:00
85c8db1866 refactor: 기존의 예약 entity를 새로 정의한 entity로 대체 2025-09-07 21:41:57 +09:00
485f8bd3f2 feat: 테마 서비스 내 조회 기능 추가 2025-09-07 21:40:03 +09:00
0ff7702c83 feat: schedule의 요약 정보를 제공하는 메서드 추가 & 날짜로 조회할 때 DISTINCT 쿼리 추가 2025-09-07 21:39:28 +09:00
3243c936c7 delete: Payment 기능 구현 완료로 인한 기존 코드 제거 2025-09-07 21:30:20 +09:00
1c961803e0 feat: 결제 승인 / 취소 API 추가 2025-09-07 21:29:27 +09:00
7f4af4770d refactor: payment 전용 API 구성을 위한 PaymentService 개선 2025-09-07 21:29:04 +09:00
d62bd444f1 refactor: payment 도메인 내 일부 DTO 수정 및 Tosspayment -> Tosspay 접두사 수정 2025-09-07 21:27:44 +09:00
6c093aeb39 refactor: payment 도메인 내 Entity & Repository 재정의 및 V2 접미사 제거 2025-09-07 21:26:59 +09:00
6d8b85a9e3 refactor: 새로운 엔티티에 맞춘 PaymentWriter 수정 2025-09-07 21:23:01 +09:00
f3707cbc69 refactor: payment 도메인에서의 dto 응답 형식 수정 및 일부 추가 2025-09-07 21:21:54 +09:00
c43836c2dc refactor: member 도매인 내 findById가 Entity가 아닌 DTO를 반혼하도록 수정 2025-09-07 21:20:30 +09:00
0ac0277714 feat: 새로운 예약 / 취소된 예약 Schema & Entity 정의 2025-09-07 18:33:26 +09:00
04d1510bd1 feat: Schedule을 반영한 예약 및 조회 프론트엔드 페이지 추가 2025-09-07 18:31:31 +09:00
d1d81b89b8 refactor: BaseEntity를 단순 PK / Audit 으로 구분 2025-09-04 18:55:24 +09:00
acfe787d5f feat: 일정 선택 후 예약 페이지로 넘어갈 때 해당 일정의 상태를 변경하는 API 추가 2025-09-04 18:54:39 +09: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 LoginPageV2 from './pages/v2/LoginPageV2';
import SignupPageV2 from './pages/v2/SignupPageV2';
import ReservationFormPage from './pages/v2/ReservationFormPage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
@ -72,6 +73,7 @@ function App() {
{/* V2.1 Reservation Flow */}
<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/success" element={<ReservationSuccessPageV21 />} />
</Routes>

View File

@ -17,3 +17,9 @@ export interface SignupResponse {
id: 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);
};
// 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
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 { TimeRetrieveResponse } from '@_api/time/timeTypes';
@ -77,18 +78,6 @@ export interface ReservationSearchQuery {
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 = {
IN_PROGRESS: '결제 진행 중',
DONE: '결제 완료',
@ -123,7 +112,7 @@ export interface ReservationPaymentRequest {
paymentKey: string;
orderId: string;
amount: number;
paymentType: PaymentType
paymentType: PaymentType;
}
export interface ReservationPaymentResponse {
@ -133,75 +122,14 @@ export interface ReservationPaymentResponse {
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 {
id: string;
user: UserDetailV2;
user: MemberSummaryRetrieveResponse;
themeName: string;
date: string;
startAt: string;
applicationDateTime: string;
payment: PaymentV2;
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;
payment: PaymentRetrieveResponse;
}

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> => {
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 {
AVAILABLE = 'AVAILABLE',
PENDING = 'PENDING',
HOLD = 'HOLD',
RESERVED = 'RESERVED',
BLOCKED = 'BLOCKED',
}

View File

@ -417,4 +417,81 @@
}
.modal-actions .confirm-button:hover {
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) {
case ScheduleStatus.AVAILABLE:
return '예약 가능';
case ScheduleStatus.PENDING:
case ScheduleStatus.HOLD:
return '예약 진행 중';
case ScheduleStatus.RESERVED:
return '예약 완료';

View File

@ -193,7 +193,7 @@ const AdminThemeEditPage: React.FC = () => {
<div className="form-row">
<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} />
</div>
<div className="form-group">

View File

@ -37,7 +37,7 @@ const AdminThemePage: React.FC = () => {
navigate('/admin/theme/edit/new');
};
const handleManageClick = (themeId: number) => {
const handleManageClick = (themeId: string) => {
navigate(`/admin/theme/edit/${themeId}`);
};
@ -54,7 +54,7 @@ const AdminThemePage: React.FC = () => {
<tr>
<th></th>
<th></th>
<th></th>
<th>1 </th>
<th></th>
<th></th>
</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 {
cancelReservationV2,
fetchMyReservationsV2,
fetchReservationDetailV2
} from '../../api/reservation/reservationAPI';
import type { PaymentV2, ReservationDetailV2, ReservationSummaryV2 } from '../../api/reservation/reservationTypes';
import '../../css/my-reservation-v2.css';
const formatDisplayDateTime = (dateTime: any): string => {
@ -78,7 +76,7 @@ const formatCardDateTime = (dateStr: string, timeStr: string): string => {
// --- Cancellation View Component ---
const CancellationView: React.FC<{
reservation: ReservationDetailV2;
reservation: ReservationDetail;
onCancelSubmit: (reason: string) => void;
onBack: () => void;
isCancelling: boolean;
@ -119,13 +117,12 @@ const CancellationView: React.FC<{
};
// --- Reservation Detail View Component ---
const ReservationDetailView: React.FC<{
reservation: ReservationDetailV2;
reservation: ReservationDetail;
onGoToCancel: () => void;
}> = ({ reservation, onGoToCancel }) => {
const renderPaymentDetails = (payment: PaymentV2) => {
const renderPaymentDetails = (payment: PaymentRetrieveResponse) => {
const { detail } = payment;
switch (detail.type) {
@ -178,8 +175,8 @@ const ReservationDetailView: React.FC<{
<h3> </h3>
<p><strong> :</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
<p><strong> :</strong> {reservation.user.name}</p>
<p><strong> :</strong> {reservation.user.email}</p>
<p><strong> :</strong> {reservation.member.name}</p>
<p><strong> :</strong> {reservation.member.email}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
</div>
<div className="modal-section-v2">
@ -188,13 +185,13 @@ const ReservationDetailView: React.FC<{
{renderPaymentDetails(reservation.payment)}
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
</div>
{reservation.cancellation && (
{reservation.payment.cancellation && (
<div className="modal-section-v2 cancellation-section-v2">
<h3> </h3>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationRequestedAt)}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationApprovedAt)}</p>
<p><strong> :</strong> {reservation.cancellation.cancelReason}</p>
<p><strong> :</strong> {reservation.cancellation.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationRequestedAt)}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationApprovedAt)}</p>
<p><strong> :</strong> {reservation.payment.cancellation.cancelReason}</p>
<p><strong> :</strong> {reservation.payment.cancellation.canceledBy == reservation.member.id ? '회원 본인' : '관리자'}</p>
</div>
)}
{reservation.payment.status !== 'CANCELED' && (
@ -208,11 +205,11 @@ const ReservationDetailView: React.FC<{
// --- Main Page Component ---
const MyReservationPageV2: React.FC = () => {
const [reservations, setReservations] = useState<ReservationSummaryV2[]>([]);
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
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 [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
@ -223,7 +220,7 @@ const MyReservationPageV2: React.FC = () => {
const loadReservations = async () => {
try {
setIsLoading(true);
const data = await fetchMyReservationsV2();
const data = await fetchSummaryByMember();
setReservations(data.reservations);
setError(null);
} catch (err) {
@ -237,14 +234,21 @@ const MyReservationPageV2: React.FC = () => {
loadReservations();
}, []);
const handleShowDetail = async (id: string) => {
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
try {
setIsDetailLoading(true);
setDetailError(null);
setModalView('detail');
const detailData = await fetchReservationDetailV2(id);
console.log('상세 정보:', detailData);
setSelectedReservation(detailData);
const detailData = await fetchDetailById(id);
setSelectedReservation({
id: detailData.id,
themeName: themeName,
date: date,
startAt: time,
member: detailData.member,
applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment
});
setIsModalOpen(true);
} catch (err) {
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
@ -268,16 +272,18 @@ const MyReservationPageV2: React.FC = () => {
try {
setIsCancelling(true);
setDetailError(null);
await cancelReservationV2(selectedReservation.id, reason);
await cancelReservation(selectedReservation.id, reason);
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal();
loadReservations(); // Refresh the list
await loadReservations(); // Refresh the list
} catch (err) {
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
} finally {
setIsCancelling(false);
setIsCancelling(true);
}
};
console.log("reservations=", reservations);
return (
<div className="my-reservation-container-v2">
@ -289,14 +295,13 @@ const MyReservationPageV2: React.FC = () => {
{!isLoading && !error && (
<div className="reservation-list-v2">
{reservations.map((res) => (
console.log(res),
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toLowerCase()}`}>
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toString().toLowerCase()}`}>
<div className="summary-details-v2">
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
</div>
<button
onClick={() => handleShowDetail(res.id)}
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
disabled={isDetailLoading}
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 { createPendingReservation } from '@_api/reservation/reservationAPI';
import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import { holdSchedule, findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import { findThemesByIds } from '@_api/theme/themeAPI';
import { Difficulty } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css';
@ -68,7 +67,9 @@ const ReservationStep1PageV21: React.FC = () => {
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
findAvailableThemesByDate(dateStr)
.then(res => {
console.log('Available themes response:', res);
const themeIds: string[] = res.themeIds;
console.log('Available theme IDs:', themeIds);
if (themeIds.length > 0) {
return findThemesByIds({ themeIds });
} else {
@ -104,23 +105,26 @@ const ReservationStep1PageV21: React.FC = () => {
alert('날짜, 테마, 시간을 모두 선택해주세요.');
return;
}
if (selectedSchedule.status !== 'AVAILABLE') {
if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
alert('예약할 수 없는 시간입니다.');
return;
}
setIsConfirmModalOpen(true);
};
const handleConfirmPayment = () => {
if (!selectedDate || !selectedTheme || !selectedSchedule) return;
const handleConfirmReservation = () => {
if (!selectedSchedule) return;
const reservationData = {
scheduleId: selectedSchedule.id,
};
createPendingReservation(reservationData)
.then((res) => {
navigate('/v2-1/reservation/payment', { state: { reservation: res } });
holdSchedule(selectedSchedule.id)
.then(() => {
navigate('/v2/reservation/form', {
state: {
scheduleId: selectedSchedule.id,
theme: selectedTheme,
date: selectedDate.toLocaleDateString('en-CA'),
time: selectedSchedule.time,
}
});
})
.catch(handleError)
.finally(() => setIsConfirmModalOpen(false));
@ -197,8 +201,19 @@ const ReservationStep1PageV21: React.FC = () => {
setSelectedTheme(theme);
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 (
<div className="reservation-v21-container">
@ -221,7 +236,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="theme-info">
<h4>{theme.name}</h4>
<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> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
@ -240,11 +255,11 @@ const ReservationStep1PageV21: React.FC = () => {
{schedules.length > 0 ? schedules.map(schedule => (
<div
key={schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`}
onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
>
{schedule.time}
<span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span>
<span className="time-availability">{getStatusText(schedule.status)}</span>
</div>
)) : <div className="no-times"> .</div>}
</div>
@ -252,7 +267,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="next-step-button-container">
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
</button>
</div>
@ -267,7 +282,7 @@ const ReservationStep1PageV21: React.FC = () => {
<p><strong>:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</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 className="modal-section">
<h3></h3>
@ -286,11 +301,10 @@ const ReservationStep1PageV21: React.FC = () => {
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
<p><strong>:</strong> {selectedTheme!!.name}</p>
<p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p>
<p><strong>:</strong> {selectedTheme!!.price.toLocaleString()}</p>
</div>
<div className="modal-actions">
<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>

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

View File

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

View File

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

View File

@ -1,11 +1,6 @@
package roomescape.common.entity
import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.Id
import jakarta.persistence.MappedSuperclass
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
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.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import kotlin.jvm.Transient
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
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
@Column(name = "id")
private val _id: Long,
@ -24,25 +41,6 @@ abstract class AuditingBaseEntity(
@Transient
private var isNewEntity: Boolean = true
) : 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
@PrePersist

View File

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

View File

@ -45,6 +45,7 @@ class ApiLogMessageConverter(
fun convertToResponseMessage(request: ConvertResponseMessageRequest): String {
val payload: MutableMap<String, Any> = mutableMapOf()
payload["type"] = request.type
payload["endpoint"] = request.endpoint
payload["status_code"] = request.httpStatus
MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull()
@ -75,8 +76,11 @@ class ApiLogMessageConverter(
data class ConvertResponseMessageRequest(
val type: LogType,
val endpoint: String,
val httpStatus: Int = 200,
val startTime: Long? = null,
val body: Any? = 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 controllerPayload: Map<String, Any> = parsePayload(joinPoint)
val servletRequest: HttpServletRequest = servletRequest()
log.info {
messageConverter.convertToControllerInvokedMessage(servletRequest(), controllerPayload)
messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
}
try {
return joinPoint.proceed()
.also { logSuccess(startTime, it) }
.also { logSuccess(servletRequest.getEndpoint(), startTime, it) }
} catch (e: Exception) {
throw e
}
}
private fun logSuccess(startTime: Long, result: Any) {
private fun logSuccess(endpoint: String, startTime: Long, result: Any) {
val responseEntity = result as ResponseEntity<*>
var convertResponseMessageRequest = ConvertResponseMessageRequest(
type = LogType.CONTROLLER_SUCCESS,
endpoint = endpoint,
httpStatus = responseEntity.statusCode.value(),
startTime = startTime,
)
if (log.isDebugEnabled()) {
convertResponseMessageRequest = convertResponseMessageRequest.copy(
convertResponseMessageRequest = convertResponseMessageRequest.copy(
body = responseEntity.body
)
}

View File

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

View File

@ -22,7 +22,7 @@ class MemberEntity(
@Column(name = "role", nullable = false, length = 20)
@Enumerated(value = EnumType.STRING)
var role: Role
): BaseEntity() {
) : BaseEntity() {
override fun getId(): Long? = _id
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 roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveResponse(
id = id!!,
@ -39,3 +40,17 @@ fun MemberEntity.toSignupResponse(): SignupResponse = SignupResponse(
id = this.id!!,
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
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.PaymentFinder
import roomescape.payment.implement.PaymentWriter
import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import roomescape.payment.web.PaymentCreateResponse
import roomescape.payment.web.toCreateResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
import roomescape.common.util.TransactionExecutionUtil
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.PaymentClientCancelResponse
import roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
import roomescape.payment.infrastructure.client.TosspayClient
import roomescape.payment.infrastructure.persistence.*
import roomescape.payment.web.*
private val log = KotlinLogging.logger {}
private val log: KLogger = KotlinLogging.logger {}
@Service
class PaymentService(
private val paymentFinder: PaymentFinder,
private val paymentWriter: PaymentWriter
private val paymentClient: TosspayClient,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val paymentWriter: PaymentWriter,
private val transactionExecutionUtil: TransactionExecutionUtil,
) {
@Transactional(readOnly = true)
fun existsByReservationId(reservationId: Long): Boolean {
log.debug { "[PaymentService.existsByReservationId] 시작: reservationId=$reservationId" }
return paymentFinder.existsPaymentByReservationId(reservationId)
.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
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse {
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm(
paymentKey = request.paymentKey,
orderId = request.orderId,
amount = request.amount,
)
return created.toCreateResponse()
.also { log.info { "[PaymentService.createPayment] 완료: paymentKey=${it.paymentKey}, reservationId=${reservation.id}, paymentId=${it.id}" } }
}
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
val payment: PaymentEntity = paymentWriter.createPayment(
reservationId = reservationId,
orderId = request.orderId,
paymentType = request.paymentType,
paymentClientConfirmResponse = clientConfirmResponse
)
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
@Transactional
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}" }
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
}
}
@Transactional
fun createCanceledPayment(reservationId: Long): PaymentCancelRequest {
log.debug { "[PaymentService.createCanceledPayment] 시작: reservationId=$reservationId" }
fun cancel(memberId: Long, request: PaymentCancelRequest) {
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
val payment: PaymentEntity = paymentFinder.findByReservationId(reservationId)
val canceled: CanceledPaymentEntity = paymentWriter.createCanceled(
payment = payment,
cancelReason = "예약 취소",
canceledAt = OffsetDateTime.now(),
val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel(
paymentKey = payment.paymentKey,
amount = payment.totalAmount,
cancelReason = request.cancelReason
)
return PaymentCancelRequest(canceled.paymentKey, canceled.cancelAmount, canceled.cancelReason)
.also { log.info { "[PaymentService.createCanceledPayment] 완료: reservationId=$reservationId, paymentKey=${it.paymentKey}" } }
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
paymentWriter.cancel(
memberId = memberId,
payment = payment,
requestedAt = request.requestedAt,
cancelResponse = clientCancelResponse
)
}.also {
log.info { "[PaymentService.cancel] 결제 취소 완료: paymentId=${payment.id}" }
}
}
@Transactional
fun updateCanceledTime(
paymentKey: String,
canceledAt: OffsetDateTime,
) {
log.debug { "[PaymentService.updateCanceledTime] 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" }
@Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentRetrieveResponse {
val payment: PaymentEntity = findByReservationIdOrThrow(reservationId)
val paymentDetail: PaymentDetailEntity = findDetailByPaymentIdOrThrow(payment.id)
val cancelDetail: CanceledPaymentEntity? = canceledPaymentRepository.findByPaymentId(payment.id)
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 io.github.oshai.kotlinlogging.KLogger
@ -7,39 +7,40 @@ import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.payment.exception.PaymentErrorCode
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.persistence.v2.*
import roomescape.reservation.web.ReservationPaymentRequest
import roomescape.payment.infrastructure.common.PaymentType
import roomescape.payment.infrastructure.persistence.*
import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentWriterV2(
private val paymentRepository: PaymentRepositoryV2,
class PaymentWriter(
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepositoryV2,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val tsidFactory: TsidFactory,
) {
fun createPayment(
reservationId: Long,
request: ReservationPaymentRequest,
paymentConfirmResponse: PaymentConfirmResponse
): PaymentEntityV2 {
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${request.paymentKey}" }
orderId: String,
paymentType: PaymentType,
paymentClientConfirmResponse: PaymentClientConfirmResponse
): PaymentEntity {
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
return paymentConfirmResponse.toEntity(
id = tsidFactory.next(), reservationId, request.orderId, request.paymentType
return paymentClientConfirmResponse.toEntity(
id = tsidFactory.next(), reservationId, orderId, paymentType
).also {
paymentRepository.save(it)
createDetail(paymentConfirmResponse, it.id)
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, paymentId=${it.id}" }
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
}
}
private fun createDetail(
paymentResponse: PaymentConfirmResponse,
fun createDetail(
paymentResponse: PaymentClientConfirmResponse,
paymentId: Long,
): PaymentDetailEntity {
val method: PaymentMethod = paymentResponse.method
@ -57,24 +58,24 @@ class PaymentWriterV2(
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
}
fun createCanceledPayment(
fun cancel(
memberId: Long,
payment: PaymentEntityV2,
payment: PaymentEntity,
requestedAt: LocalDateTime,
cancelResponse: PaymentCancelResponseV2
) {
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 시작: paymentId=${payment.id}, paymentKey=${payment.paymentKey}" }
cancelResponse: PaymentClientCancelResponse
): CanceledPaymentEntity {
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(),
paymentId = payment.id,
cancelRequestedAt = requestedAt,
canceledBy = memberId
)
canceledPaymentRepository.save(canceledPayment).also {
payment.cancel()
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 완료: paymentId=${payment.id}, canceledPaymentId=${it.id}, paymentKey=${payment.paymentKey}" }
).also {
canceledPaymentRepository.save(it)
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
}
}
}

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 {
@Bean
fun tossPaymentClientBuilder(
fun tosspayClientBuilder(
paymentProperties: PaymentProperties,
): RestClient.Builder {
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.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
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.OffsetDateTime
data class PaymentCancelRequestV2(
val paymentKey: String,
val amount: Int,
val cancelReason: String
)
data class PaymentCancelResponseV2(
data class PaymentClientCancelResponse(
val status: PaymentStatus,
@JsonDeserialize(using = CancelDetailDeserializer::class)
val cancels: CancelDetail,
@ -36,7 +29,7 @@ fun CancelDetail.toEntity(
paymentId: Long,
canceledBy: Long,
cancelRequestedAt: LocalDateTime
) = CanceledPaymentEntityV2(
) = CanceledPaymentEntity(
id = id,
canceledAt = this.canceledAt,
requestedAt = cancelRequestedAt,
@ -49,7 +42,7 @@ fun CancelDetail.toEntity(
easypayDiscountAmount = this.easyPayDiscountAmount
)
class CancelDetailDeserializer : JsonDeserializer<CancelDetail>() {
class CancelDetailDeserializer : com.fasterxml.jackson.databind.JsonDeserializer<CancelDetail>() {
override fun deserialize(
p: JsonParser,
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.PaymentException
import roomescape.payment.infrastructure.common.*
import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
import roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
import roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
import roomescape.payment.infrastructure.persistence.PaymentEntity
import java.time.OffsetDateTime
data class PaymentConfirmRequest(
val paymentKey: String,
val orderId: String,
val amount: Int,
)
data class PaymentConfirmResponse(
data class PaymentClientConfirmResponse(
val paymentKey: String,
val status: PaymentStatus,
val totalAmount: Int,
@ -29,12 +23,12 @@ data class PaymentConfirmResponse(
val approvedAt: OffsetDateTime,
)
fun PaymentConfirmResponse.toEntity(
fun PaymentClientConfirmResponse.toEntity(
id: Long,
reservationId: Long,
orderId: String,
paymentType: PaymentType
) = PaymentEntityV2(
) = PaymentEntity(
id = id,
reservationId = reservationId,
paymentKey = this.paymentKey,
@ -58,7 +52,7 @@ data class CardDetail(
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)
return PaymentCardDetailEntity(
@ -85,7 +79,7 @@ data class EasyPayDetail(
val discountAmount: Int,
)
fun PaymentConfirmResponse.toEasypayPrepaidDetailEntity(
fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
id: Long,
paymentId: Long
): PaymentEasypayPrepaidDetailEntity {
@ -107,7 +101,7 @@ data class TransferDetail(
val settlementStatus: String,
)
fun PaymentConfirmResponse.toTransferDetailEntity(
fun PaymentClientConfirmResponse.toTransferDetailEntity(
id: Long,
paymentId: Long
): 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)
fun get(name: String): PaymentType {
return CACHE[name.uppercase()] ?: run {
log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$name" }
throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND)
}
log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$name" }
throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND)
}
}
}
}
@ -163,9 +163,9 @@ enum class BankCode(
val parsedCode = if (code.length == 2) "0$code" else code
return CACHE[parsedCode] ?: run {
log.error { "[PaymentCode.BankCode] 은행 코드 조회 실패: code=$code" }
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
}
log.error { "[PaymentCode.BankCode] 은행 코드 조회 실패: code=$code" }
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
}
}
}
}
@ -207,9 +207,9 @@ enum class CardIssuerCode(
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun get(code: String): CardIssuerCode {
return CACHE[code] ?: run {
log.error { "[PaymentCode.CardIssuerCode] 카드사 코드 조회 실패: code=$code" }
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
}
log.error { "[PaymentCode.CardIssuerCode] 카드사 코드 조회 실패: code=$code" }
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
}
}
}
}

View File

@ -1,33 +1,24 @@
package roomescape.payment.infrastructure.persistence
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import roomescape.common.entity.BaseEntity
import roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime
@Entity
@Table(name = "canceled_payments")
@Table(name = "canceled_payment")
class CanceledPaymentEntity(
@Id
@Column(name = "canceled_payment_id")
private var _id: Long?,
id: Long,
@Column(name = "payment_key", nullable = false)
var paymentKey: String,
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)
@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
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 roomescape.common.entity.PersistableBaseEntity
import roomescape.payment.infrastructure.common.*
import kotlin.jvm.Transient
@Entity
@Table(name = "payment_detail")
@ -14,9 +13,6 @@ open class PaymentDetailEntity(
open val paymentId: Long,
open val suppliedAmount: Int,
open val vat: Int,
@Transient
private var isNewEntity: Boolean = true
) : PersistableBaseEntity(id)
@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
import jakarta.persistence.*
import roomescape.common.entity.BaseEntity
import roomescape.reservation.infrastructure.persistence.ReservationEntity
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 = "payments")
@Table(name = "payment")
class PaymentEntity(
@Id
@Column(name = "payment_id")
private var _id: Long?,
id: Long,
@Column(name = "order_id", nullable = false)
var orderId: String,
val reservationId: Long,
val paymentKey: String,
val orderId: String,
val totalAmount: Int,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
@Column(name="payment_key", nullable = false)
var paymentKey: String,
@Enumerated(EnumType.STRING)
val type: PaymentType,
@Column(name="total_amount", nullable = false)
var totalAmount: Long,
@Enumerated(EnumType.STRING)
val method: PaymentMethod,
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reservation_id", nullable = false)
var reservation: ReservationEntity,
@Enumerated(EnumType.STRING)
var status: PaymentStatus
) : PersistableBaseEntity(id) {
@Column(name="approved_at", nullable = false)
var approvedAt: OffsetDateTime
): BaseEntity() {
override fun getId(): Long? = _id
fun cancel() {
this.status = PaymentStatus.CANCELED
}
}

View File

@ -4,9 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository
interface PaymentRepository : JpaRepository<PaymentEntity, Long> {
fun existsByReservationId(reservationId: Long): Boolean
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
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
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
data class PaymentCancelRequest(
data class PaymentConfirmRequest(
val paymentKey: String,
val amount: Long,
val cancelReason: String
)
@JsonDeserialize(using = PaymentCancelResponseDeserializer::class)
data class PaymentCancelResponse(
val cancelStatus: String,
val cancelReason: String,
val cancelAmount: Long,
val canceledAt: OffsetDateTime
val orderId: String,
val amount: Int,
val paymentType: PaymentType
)
data class PaymentCreateResponse(
val id: Long,
val orderId: String,
val paymentKey: String,
val totalAmount: Long,
val reservationId: Long,
val approvedAt: OffsetDateTime
val paymentId: Long,
val detailId: Long
)
fun PaymentEntity.toCreateResponse() = PaymentCreateResponse(
id = this.id!!,
orderId = this.orderId,
paymentKey = this.paymentKey,
totalAmount = this.totalAmount,
reservationId = this.reservation.id!!,
approvedAt = this.approvedAt
data class PaymentCancelRequest(
val reservationId: Long,
val cancelReason: String,
val requestedAt: LocalDateTime = LocalDateTime.now()
)
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.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 io.swagger.v3.oas.annotations.tags.Tag
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 org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.*
import java.time.LocalDate
@Tag(name = "3. 예약 API", description = "예약 및 대기 정보를 추가 / 조회 / 삭제할 때 사용합니다.")
interface ReservationAPI {
@Admin
@Operation(summary = "모든 예약 정보 조회", tags = ["관리자 로그인이 필요한 API"])
@LoginRequired
@Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"])
@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
@Operation(summary = "자신의 예약 및 대기 조회", tags = ["로그인이 필요한 API"])
@Operation(summary = "예약 확정", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findReservationsByMemberId(
@MemberId @Parameter(hidden = true) memberId: 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
fun confirmReservation(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired
@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"])
@Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"])
@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
@Operation(summary = "예약 대기 신청", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "201",
description = "성공",
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>>
@Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findSummaryByMemberId(
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>>
@LoginRequired
@Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "성공"),
)
fun cancelWaitingByMember(
@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>>
@Operation(summary = "특정 예약에 대한 상세 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findDetailById(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>>
}

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
) : ErrorCode {
RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."),
RESERVATION_DUPLICATED(HttpStatus.BAD_REQUEST, "R002", "이미 같은 예약이 있어요."),
ALREADY_RESERVE(HttpStatus.BAD_REQUEST, "R003", "같은 날짜, 시간, 테마에 대한 예약(대기)는 한 번만 가능해요."),
ALREADY_CONFIRMED(HttpStatus.CONFLICT, "R004", "이미 확정된 예약이에요"),
CONFIRMED_RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "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", "결제 대기 중인 예약이 아니에요."),
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
SCHEDULE_NOT_HOLD(HttpStatus.BAD_REQUEST, "R004", "이미 예약되었거나 예약이 불가능한 일정이에요."),
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.")
;
}

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

View File

@ -1,75 +1,8 @@
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.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
: JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> {
fun findAllByTime(time: TimeEntity): List<ReservationEntity>
fun existsByTime(time: TimeEntity): Boolean
interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
fun findByDateAndThemeId(date: LocalDate, themeId: 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>
fun findAllByMemberId(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 roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.payment.infrastructure.client.PaymentApproveRequest
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.business.ReservationService
import roomescape.reservation.docs.ReservationAPI
import java.net.URI
import java.time.LocalDate
@RestController
class ReservationController(
private val reservationWithPaymentService: ReservationWithPaymentService,
private val reservationFindService: ReservationFindService,
private val reservationWriteService: ReservationWriteService,
private val paymentClient: TossPaymentClient
private val reservationService: ReservationService
) : ReservationAPI {
@GetMapping("/reservations")
override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationFindService.findReservations()
@PostMapping("/reservations/pending")
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))
}
@GetMapping("/reservations-mine")
override fun findReservationsByMemberId(
@PostMapping("/reservations/{id}/confirm")
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
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> {
val response: MyReservationRetrieveListResponse = reservationFindService.findReservationsByMemberId(memberId)
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>> {
val response = reservationService.findSummaryByMemberId(memberId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/reservations/search")
override 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>> {
val response: ReservationRetrieveListResponse = reservationFindService.searchReservations(
themeId,
memberId,
dateFrom,
dateTo
)
@GetMapping("/reservations/{id}/detail")
override fun findDetailById(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>> {
val response = reservationService.findDetailById(id)
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 {
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" }
return scheduleRepository.findAllByDate(date)
.toThemeIdListResponse()
return AvailableThemeIdListResponse(scheduleRepository.findAllUniqueThemeIdByDate(date))
.also {
log.info { "[ScheduleService.findThemesByDate] date=${date}${it.themeIds.size}개 테마 조회 완료" }
}
@ -54,8 +53,8 @@ class ScheduleService(
val schedule: ScheduleEntity = findOrThrow(id)
val createdBy = memberService.findById(schedule.createdBy).name
val updatedBy = memberService.findById(schedule.updatedBy).name
val createdBy = memberService.findSummaryById(schedule.createdBy).name
val updatedBy = memberService.findSummaryById(schedule.updatedBy).name
return schedule.toDetailRetrieveResponse(createdBy, updatedBy)
.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
fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse {
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
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }

View File

@ -23,7 +23,7 @@ class ScheduleValidator(
fun validateCanDelete(schedule: ScheduleEntity) {
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}" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
}

View File

@ -38,6 +38,19 @@ interface ScheduleAPI {
@RequestParam("themeId") themeId: Long
): 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
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package roomescape.schedule.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import java.time.LocalDate
import java.time.LocalTime
@ -11,4 +12,13 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity>
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))
}
@PatchMapping("/schedules/{id}/hold")
override fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.holdSchedule(id)
return ResponseEntity.ok(CommonApiResponse())
}
@PatchMapping("/schedules/{id}")
override fun updateSchedule(
@PathVariable("id") id: Long,

View File

@ -10,8 +10,6 @@ data class AvailableThemeIdListResponse(
val themeIds: List<Long>
)
fun List<ScheduleEntity>.toThemeIdListResponse() = AvailableThemeIdListResponse(this.map { it.themeId })
data class ScheduleRetrieveResponse(
val id: Long,
val time: LocalTime,
@ -66,3 +64,17 @@ fun ScheduleEntity.toDetailRetrieveResponse(createdBy: String, updatedBy: String
updatedAt = this.updatedAt,
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
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.theme.implement.ThemeFinder
import roomescape.theme.implement.ThemeWriter
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.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.*
import java.time.LocalDate
private val log = KotlinLogging.logger {}
private val log: KLogger = KotlinLogging.logger {}
@Service
class ThemeService(
private val themeFinder: ThemeFinder,
private val themeWriter: ThemeWriter,
private val themeRepository: ThemeRepository,
private val tsidFactory: TsidFactory,
private val memberService: MemberService,
private val themeValidator: ThemeValidator
) {
@Transactional(readOnly = true)
fun findById(id: Long): ThemeEntity {
log.debug { "[ThemeService.findById] 시작: themeId=$id" }
fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeSummaryListResponse {
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
val result: MutableList<ThemeEntity> = mutableListOf()
return themeFinder.findById(id)
.also { log.info { "[ThemeService.findById] 완료: themeId=$id, name=${it.name}" } }
for (id in request.themeIds) {
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)
fun findThemes(): ThemeRetrieveListResponse {
log.debug { "[ThemeService.findThemes] 시작" }
fun findThemesForReservation(): ThemeSummaryListResponse {
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
return themeFinder.findAll()
return themeRepository.findOpenedThemes()
.toRetrieveListResponse()
.also { log.info { "[ThemeService.findThemes] 완료. ${it.themes.size}개 반환" } }
.also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } }
}
@Transactional(readOnly = true)
fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse {
log.debug { "[ThemeService.findMostReservedThemes] 시작: count=$count" }
fun findAdminThemes(): AdminThemeSummaryRetrieveListResponse {
log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
val today = LocalDate.now()
val startFrom = today.minusDays(7)
val endAt = today.minusDays(1)
return themeRepository.findAll()
.toAdminThemeSummaryListResponse()
.also { log.info { "[ThemeService.findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
}
return themeFinder.findMostReservedThemes(count, startFrom, endAt)
.toRetrieveListResponse()
.also { log.info { "[ThemeService.findMostReservedThemes] ${it.themes.size}개 반환" } }
@Transactional(readOnly = true)
fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse {
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
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.debug { "[ThemeService.createTheme] 시작: name=${request.name}" }
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponseV2 {
log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
return themeWriter.create(request.name, request.description, request.thumbnail)
.toCreateResponse()
.also { log.info { "[ThemeService.createTheme] 테마 생성 완료: name=${it.name} themeId=${it.id}" } }
themeValidator.validateCanCreate(request)
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
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)
.also { log.info { "[ThemeService.deleteTheme] 완료: themeId=$id, name=${theme.name}" } }
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: 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 roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2
import roomescape.theme.web.ThemeCreateRequestV2
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeUpdateRequest
private val log: KLogger = KotlinLogging.logger {}
@ -16,8 +16,8 @@ const val MIN_PARTICIPANTS = 1
const val MIN_DURATION = 1
@Component
class ThemeValidatorV2(
private val themeRepository: ThemeRepositoryV2,
class ThemeValidator(
private val themeRepository: ThemeRepository,
) {
fun validateCanUpdate(request: ThemeUpdateRequest) {
validateProperties(
@ -30,7 +30,7 @@ class ThemeValidatorV2(
)
}
fun validateCanCreate(request: ThemeCreateRequestV2) {
fun validateCanCreate(request: ThemeCreateRequest) {
if (themeRepository.existsByName(request.name)) {
log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" }
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.LoginRequired
import roomescape.common.dto.response.CommonApiResponse
import roomescape.theme.web.AdminThemeDetailRetrieveResponse
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
import roomescape.theme.web.*
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.")
interface ThemeAPIV2 {
@ -35,7 +29,7 @@ interface ThemeAPIV2 {
@Admin
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@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
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
@ -53,10 +47,10 @@ interface ThemeAPIV2 {
@LoginRequired
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
@LoginRequired
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@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