generated from pricelees/issue-pr-template
[#41] 예약 스키마 재정의 #42
@ -23,6 +23,7 @@ import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21';
|
|||||||
import HomePageV2 from './pages/v2/HomePageV2';
|
import HomePageV2 from './pages/v2/HomePageV2';
|
||||||
import LoginPageV2 from './pages/v2/LoginPageV2';
|
import LoginPageV2 from './pages/v2/LoginPageV2';
|
||||||
import SignupPageV2 from './pages/v2/SignupPageV2';
|
import SignupPageV2 from './pages/v2/SignupPageV2';
|
||||||
|
import ReservationFormPage from './pages/v2/ReservationFormPage';
|
||||||
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
||||||
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
||||||
|
|
||||||
@ -72,6 +73,7 @@ function App() {
|
|||||||
|
|
||||||
{/* V2.1 Reservation Flow */}
|
{/* V2.1 Reservation Flow */}
|
||||||
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
|
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
|
||||||
|
<Route path="/v2/reservation/form" element={<ReservationFormPage />} />
|
||||||
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
|
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
|
||||||
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
|
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -17,3 +17,9 @@ export interface SignupResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberSummaryRetrieveResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|||||||
73
frontend/src/api/payment/PaymentTypes.ts
Normal file
73
frontend/src/api/payment/PaymentTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
10
frontend/src/api/payment/paymentAPI.ts
Normal file
10
frontend/src/api/payment/paymentAPI.ts
Normal 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);
|
||||||
|
};
|
||||||
@ -85,10 +85,7 @@ export const confirmReservationPayment = async (id: string, data: ReservationPay
|
|||||||
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
|
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST /v2/reservations/{id}/cancel
|
|
||||||
export const cancelReservationV2 = async (id: string, cancelReason: string): Promise<void> => {
|
|
||||||
return await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason }, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET /v2/reservations
|
// GET /v2/reservations
|
||||||
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {
|
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {
|
||||||
|
|||||||
@ -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`);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
|
import type { MemberRetrieveResponse, MemberSummaryRetrieveResponse } from '@_api/member/memberTypes';
|
||||||
|
import type { PaymentRetrieveResponse, PaymentType } from '@_api/payment/PaymentTypes';
|
||||||
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||||
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
||||||
|
|
||||||
@ -77,18 +78,6 @@ export interface ReservationSearchQuery {
|
|||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2 types
|
|
||||||
export const PaymentType = {
|
|
||||||
NORMAL: 'NORMAL',
|
|
||||||
BILLING: 'BILLING',
|
|
||||||
BRANDPAY: 'BRANDPAY'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type PaymentType =
|
|
||||||
| typeof PaymentType.NORMAL
|
|
||||||
| typeof PaymentType.BILLING
|
|
||||||
| typeof PaymentType.BRANDPAY;
|
|
||||||
|
|
||||||
export const PaymentStatus = {
|
export const PaymentStatus = {
|
||||||
IN_PROGRESS: '결제 진행 중',
|
IN_PROGRESS: '결제 진행 중',
|
||||||
DONE: '결제 완료',
|
DONE: '결제 완료',
|
||||||
@ -123,7 +112,7 @@ export interface ReservationPaymentRequest {
|
|||||||
paymentKey: string;
|
paymentKey: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
paymentType: PaymentType
|
paymentType: PaymentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationPaymentResponse {
|
export interface ReservationPaymentResponse {
|
||||||
@ -133,75 +122,14 @@ export interface ReservationPaymentResponse {
|
|||||||
paymentStatus: PaymentStatus;
|
paymentStatus: PaymentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationSummaryV2 {
|
|
||||||
id: string;
|
|
||||||
themeName: string;
|
|
||||||
date: string;
|
|
||||||
startAt: string;
|
|
||||||
status: string; // 'CONFIRMED', 'CANCELED_BY_USER', etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReservationSummaryListV2 {
|
|
||||||
reservations: ReservationSummaryV2[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReservationDetailV2 {
|
export interface ReservationDetailV2 {
|
||||||
id: string;
|
id: string;
|
||||||
user: UserDetailV2;
|
user: MemberSummaryRetrieveResponse;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
date: string;
|
date: string;
|
||||||
startAt: string;
|
startAt: string;
|
||||||
applicationDateTime: string;
|
applicationDateTime: string;
|
||||||
payment: PaymentV2;
|
payment: PaymentRetrieveResponse;
|
||||||
cancellation: CancellationV2 | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserDetailV2 {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentV2 {
|
|
||||||
orderId: string;
|
|
||||||
totalAmount: number;
|
|
||||||
method: string;
|
|
||||||
status: 'DONE' | 'CANCELED';
|
|
||||||
requestedAt: string;
|
|
||||||
approvedAt: string;
|
|
||||||
detail: CardPaymentDetailV2 | BankTransferPaymentDetailV2 | EasyPayPrepaidPaymentDetailV2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CardPaymentDetailV2 {
|
|
||||||
type: 'CARD';
|
|
||||||
issuerCode: string;
|
|
||||||
cardType: 'CREDIT' | 'CHECK' | 'GIFT';
|
|
||||||
ownerType: 'PERSONAL' | 'CORPORATE';
|
|
||||||
cardNumber: string;
|
|
||||||
amount: number;
|
|
||||||
approvalNumber: string;
|
|
||||||
installmentPlanMonths: number;
|
|
||||||
isInterestFree: boolean;
|
|
||||||
easypayProviderName?: string;
|
|
||||||
easypayDiscountAmount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BankTransferPaymentDetailV2 {
|
|
||||||
type: 'BANK_TRANSFER';
|
|
||||||
bankName: string;
|
|
||||||
settlementStatus: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EasyPayPrepaidPaymentDetailV2 {
|
|
||||||
type: 'EASYPAY_PREPAID';
|
|
||||||
providerName: string;
|
|
||||||
amount: number;
|
|
||||||
discountAmount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CancellationV2 {
|
|
||||||
cancellationRequestedAt: string; // ISO 8601 format
|
|
||||||
cancellationApprovedAt: string; // ISO 8601 format
|
|
||||||
cancelReason: string;
|
|
||||||
canceledBy: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
58
frontend/src/api/reservation/reservationTypesV2.ts
Normal file
58
frontend/src/api/reservation/reservationTypesV2.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -30,3 +30,7 @@ export const updateSchedule = async (id: string, request: ScheduleUpdateRequest)
|
|||||||
export const deleteSchedule = async (id: string): Promise<void> => {
|
export const deleteSchedule = async (id: string): Promise<void> => {
|
||||||
await apiClient.del(`/schedules/${id}`);
|
await apiClient.del(`/schedules/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const holdSchedule = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.patch(`/schedules/${id}/hold`, {});
|
||||||
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export enum ScheduleStatus {
|
export enum ScheduleStatus {
|
||||||
AVAILABLE = 'AVAILABLE',
|
AVAILABLE = 'AVAILABLE',
|
||||||
PENDING = 'PENDING',
|
HOLD = 'HOLD',
|
||||||
RESERVED = 'RESERVED',
|
RESERVED = 'RESERVED',
|
||||||
BLOCKED = 'BLOCKED',
|
BLOCKED = 'BLOCKED',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -417,4 +417,81 @@
|
|||||||
}
|
}
|
||||||
.modal-actions .confirm-button:hover {
|
.modal-actions .confirm-button:hover {
|
||||||
background-color: #1B64DA;
|
background-color: #1B64DA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styles for ReservationFormPage */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="tel"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus, .form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3182F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control input {
|
||||||
|
text-align: center;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
width: 60px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control button:hover:not(:disabled) {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control button:disabled {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control button:first-of-type {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-control button:last-of-type {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const getScheduleStatusText = (status: ScheduleStatus): string => {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case ScheduleStatus.AVAILABLE:
|
case ScheduleStatus.AVAILABLE:
|
||||||
return '예약 가능';
|
return '예약 가능';
|
||||||
case ScheduleStatus.PENDING:
|
case ScheduleStatus.HOLD:
|
||||||
return '예약 진행 중';
|
return '예약 진행 중';
|
||||||
case ScheduleStatus.RESERVED:
|
case ScheduleStatus.RESERVED:
|
||||||
return '예약 완료';
|
return '예약 완료';
|
||||||
|
|||||||
@ -193,7 +193,7 @@ const AdminThemeEditPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="price">가격 (원)</label>
|
<label className="form-label" htmlFor="price">1인당 요금 (원)</label>
|
||||||
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
|
<input id="price" name="price" type="number" className="form-input" value={theme.price} onChange={handleChange} required disabled={!isEditing} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const AdminThemePage: React.FC = () => {
|
|||||||
navigate('/admin/theme/edit/new');
|
navigate('/admin/theme/edit/new');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManageClick = (themeId: number) => {
|
const handleManageClick = (themeId: string) => {
|
||||||
navigate(`/admin/theme/edit/${themeId}`);
|
navigate(`/admin/theme/edit/${themeId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ const AdminThemePage: React.FC = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>이름</th>
|
<th>이름</th>
|
||||||
<th>난이도</th>
|
<th>난이도</th>
|
||||||
<th>가격</th>
|
<th>1인당 요금</th>
|
||||||
<th>공개여부</th>
|
<th>공개여부</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
|
import { cancelPayment } from '@_api/payment/paymentAPI';
|
||||||
|
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
|
||||||
|
import { cancelReservation, fetchDetailById, fetchSummaryByMember } from '@_api/reservation/reservationAPIV2';
|
||||||
|
import type { ReservationDetail, ReservationSummaryRetrieveResponse } from '@_api/reservation/reservationTypesV2';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
|
||||||
cancelReservationV2,
|
|
||||||
fetchMyReservationsV2,
|
|
||||||
fetchReservationDetailV2
|
|
||||||
} from '../../api/reservation/reservationAPI';
|
|
||||||
import type { PaymentV2, ReservationDetailV2, ReservationSummaryV2 } from '../../api/reservation/reservationTypes';
|
|
||||||
import '../../css/my-reservation-v2.css';
|
import '../../css/my-reservation-v2.css';
|
||||||
|
|
||||||
const formatDisplayDateTime = (dateTime: any): string => {
|
const formatDisplayDateTime = (dateTime: any): string => {
|
||||||
@ -78,7 +76,7 @@ const formatCardDateTime = (dateStr: string, timeStr: string): string => {
|
|||||||
|
|
||||||
// --- Cancellation View Component ---
|
// --- Cancellation View Component ---
|
||||||
const CancellationView: React.FC<{
|
const CancellationView: React.FC<{
|
||||||
reservation: ReservationDetailV2;
|
reservation: ReservationDetail;
|
||||||
onCancelSubmit: (reason: string) => void;
|
onCancelSubmit: (reason: string) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
isCancelling: boolean;
|
isCancelling: boolean;
|
||||||
@ -119,13 +117,12 @@ const CancellationView: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- Reservation Detail View Component ---
|
|
||||||
const ReservationDetailView: React.FC<{
|
const ReservationDetailView: React.FC<{
|
||||||
reservation: ReservationDetailV2;
|
reservation: ReservationDetail;
|
||||||
onGoToCancel: () => void;
|
onGoToCancel: () => void;
|
||||||
}> = ({ reservation, onGoToCancel }) => {
|
}> = ({ reservation, onGoToCancel }) => {
|
||||||
|
|
||||||
const renderPaymentDetails = (payment: PaymentV2) => {
|
const renderPaymentDetails = (payment: PaymentRetrieveResponse) => {
|
||||||
const { detail } = payment;
|
const { detail } = payment;
|
||||||
|
|
||||||
switch (detail.type) {
|
switch (detail.type) {
|
||||||
@ -178,8 +175,8 @@ const ReservationDetailView: React.FC<{
|
|||||||
<h3>예약 정보</h3>
|
<h3>예약 정보</h3>
|
||||||
<p><strong>예약 테마:</strong> {reservation.themeName}</p>
|
<p><strong>예약 테마:</strong> {reservation.themeName}</p>
|
||||||
<p><strong>이용 예정일:</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
|
<p><strong>이용 예정일:</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
|
||||||
<p><strong>예약자 이름:</strong> {reservation.user.name}</p>
|
<p><strong>예약자 이름:</strong> {reservation.member.name}</p>
|
||||||
<p><strong>예약자 이메일:</strong> {reservation.user.email}</p>
|
<p><strong>예약자 이메일:</strong> {reservation.member.email}</p>
|
||||||
<p><strong>예약 신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
<p><strong>예약 신청 일시:</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-section-v2">
|
<div className="modal-section-v2">
|
||||||
@ -188,13 +185,13 @@ const ReservationDetailView: React.FC<{
|
|||||||
{renderPaymentDetails(reservation.payment)}
|
{renderPaymentDetails(reservation.payment)}
|
||||||
<p><strong>결제 승인 일시:</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
|
<p><strong>결제 승인 일시:</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
{reservation.cancellation && (
|
{reservation.payment.cancellation && (
|
||||||
<div className="modal-section-v2 cancellation-section-v2">
|
<div className="modal-section-v2 cancellation-section-v2">
|
||||||
<h3>취소 정보</h3>
|
<h3>취소 정보</h3>
|
||||||
<p><strong>취소 요청 일시:</strong> {formatDisplayDateTime(reservation.cancellation.cancellationRequestedAt)}</p>
|
<p><strong>취소 요청 일시:</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationRequestedAt)}</p>
|
||||||
<p><strong>환불 완료 일시:</strong> {formatDisplayDateTime(reservation.cancellation.cancellationApprovedAt)}</p>
|
<p><strong>환불 완료 일시:</strong> {formatDisplayDateTime(reservation.payment.cancellation.cancellationApprovedAt)}</p>
|
||||||
<p><strong>취소 사유:</strong> {reservation.cancellation.cancelReason}</p>
|
<p><strong>취소 사유:</strong> {reservation.payment.cancellation.cancelReason}</p>
|
||||||
<p><strong>취소 요청자:</strong> {reservation.cancellation.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p>
|
<p><strong>취소 요청자:</strong> {reservation.payment.cancellation.canceledBy == reservation.member.id ? '회원 본인' : '관리자'}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reservation.payment.status !== 'CANCELED' && (
|
{reservation.payment.status !== 'CANCELED' && (
|
||||||
@ -208,11 +205,11 @@ const ReservationDetailView: React.FC<{
|
|||||||
|
|
||||||
// --- Main Page Component ---
|
// --- Main Page Component ---
|
||||||
const MyReservationPageV2: React.FC = () => {
|
const MyReservationPageV2: React.FC = () => {
|
||||||
const [reservations, setReservations] = useState<ReservationSummaryV2[]>([]);
|
const [reservations, setReservations] = useState<ReservationSummaryRetrieveResponse[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [selectedReservation, setSelectedReservation] = useState<ReservationDetailV2 | null>(null);
|
const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||||
const [detailError, setDetailError] = useState<string | null>(null);
|
const [detailError, setDetailError] = useState<string | null>(null);
|
||||||
@ -223,7 +220,7 @@ const MyReservationPageV2: React.FC = () => {
|
|||||||
const loadReservations = async () => {
|
const loadReservations = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data = await fetchMyReservationsV2();
|
const data = await fetchSummaryByMember();
|
||||||
setReservations(data.reservations);
|
setReservations(data.reservations);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -237,14 +234,21 @@ const MyReservationPageV2: React.FC = () => {
|
|||||||
loadReservations();
|
loadReservations();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleShowDetail = async (id: string) => {
|
const handleShowDetail = async (id: string, themeName: string, date: string, time: string) => {
|
||||||
try {
|
try {
|
||||||
setIsDetailLoading(true);
|
setIsDetailLoading(true);
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setModalView('detail');
|
setModalView('detail');
|
||||||
const detailData = await fetchReservationDetailV2(id);
|
const detailData = await fetchDetailById(id);
|
||||||
console.log('상세 정보:', detailData);
|
setSelectedReservation({
|
||||||
setSelectedReservation(detailData);
|
id: detailData.id,
|
||||||
|
themeName: themeName,
|
||||||
|
date: date,
|
||||||
|
startAt: time,
|
||||||
|
member: detailData.member,
|
||||||
|
applicationDateTime: detailData.applicationDateTime,
|
||||||
|
payment: detailData.payment
|
||||||
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
|
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
|
||||||
@ -268,16 +272,18 @@ const MyReservationPageV2: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsCancelling(true);
|
setIsCancelling(true);
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
await cancelReservationV2(selectedReservation.id, reason);
|
await cancelReservation(selectedReservation.id, reason);
|
||||||
|
cancelPayment({ reservationId: selectedReservation.id, cancelReason: reason });
|
||||||
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
|
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
loadReservations(); // Refresh the list
|
await loadReservations(); // Refresh the list
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
|
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsCancelling(false);
|
setIsCancelling(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
console.log("reservations=", reservations);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-reservation-container-v2">
|
<div className="my-reservation-container-v2">
|
||||||
@ -289,14 +295,13 @@ const MyReservationPageV2: React.FC = () => {
|
|||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
<div className="reservation-list-v2">
|
<div className="reservation-list-v2">
|
||||||
{reservations.map((res) => (
|
{reservations.map((res) => (
|
||||||
console.log(res),
|
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toString().toLowerCase()}`}>
|
||||||
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toLowerCase()}`}>
|
|
||||||
<div className="summary-details-v2">
|
<div className="summary-details-v2">
|
||||||
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
|
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
|
||||||
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
|
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleShowDetail(res.id)}
|
onClick={() => handleShowDetail(res.id, res.themeName, res.date, res.startAt)}
|
||||||
disabled={isDetailLoading}
|
disabled={isDetailLoading}
|
||||||
className="detail-button-v2"
|
className="detail-button-v2"
|
||||||
>
|
>
|
||||||
|
|||||||
121
frontend/src/pages/v2/ReservationFormPage.tsx
Normal file
121
frontend/src/pages/v2/ReservationFormPage.tsx
Normal 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;
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
import { createPendingReservation } from '@_api/reservation/reservationAPI';
|
import { holdSchedule, findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
|
||||||
import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
|
import { ScheduleStatus, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
||||||
import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
|
|
||||||
import { findThemesByIds } from '@_api/theme/themeAPI';
|
import { findThemesByIds } from '@_api/theme/themeAPI';
|
||||||
import { Difficulty } from '@_api/theme/themeTypes';
|
import { Difficulty } from '@_api/theme/themeTypes';
|
||||||
import '@_css/reservation-v2-1.css';
|
import '@_css/reservation-v2-1.css';
|
||||||
@ -68,7 +67,9 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
|
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
|
||||||
findAvailableThemesByDate(dateStr)
|
findAvailableThemesByDate(dateStr)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
console.log('Available themes response:', res);
|
||||||
const themeIds: string[] = res.themeIds;
|
const themeIds: string[] = res.themeIds;
|
||||||
|
console.log('Available theme IDs:', themeIds);
|
||||||
if (themeIds.length > 0) {
|
if (themeIds.length > 0) {
|
||||||
return findThemesByIds({ themeIds });
|
return findThemesByIds({ themeIds });
|
||||||
} else {
|
} else {
|
||||||
@ -104,23 +105,26 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
alert('날짜, 테마, 시간을 모두 선택해주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedSchedule.status !== 'AVAILABLE') {
|
if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
|
||||||
alert('예약할 수 없는 시간입니다.');
|
alert('예약할 수 없는 시간입니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsConfirmModalOpen(true);
|
setIsConfirmModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmPayment = () => {
|
const handleConfirmReservation = () => {
|
||||||
if (!selectedDate || !selectedTheme || !selectedSchedule) return;
|
if (!selectedSchedule) return;
|
||||||
|
|
||||||
const reservationData = {
|
holdSchedule(selectedSchedule.id)
|
||||||
scheduleId: selectedSchedule.id,
|
.then(() => {
|
||||||
};
|
navigate('/v2/reservation/form', {
|
||||||
|
state: {
|
||||||
createPendingReservation(reservationData)
|
scheduleId: selectedSchedule.id,
|
||||||
.then((res) => {
|
theme: selectedTheme,
|
||||||
navigate('/v2-1/reservation/payment', { state: { reservation: res } });
|
date: selectedDate.toLocaleDateString('en-CA'),
|
||||||
|
time: selectedSchedule.time,
|
||||||
|
}
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
.finally(() => setIsConfirmModalOpen(false));
|
.finally(() => setIsConfirmModalOpen(false));
|
||||||
@ -197,8 +201,19 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
setSelectedTheme(theme);
|
setSelectedTheme(theme);
|
||||||
setIsThemeModalOpen(true);
|
setIsThemeModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: ScheduleStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case ScheduleStatus.AVAILABLE:
|
||||||
|
return '예약가능';
|
||||||
|
case ScheduleStatus.HOLD:
|
||||||
|
return '예약 진행중';
|
||||||
|
default:
|
||||||
|
return '예약불가';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE';
|
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
@ -221,7 +236,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
<div className="theme-info">
|
<div className="theme-info">
|
||||||
<h4>{theme.name}</h4>
|
<h4>{theme.name}</h4>
|
||||||
<div className="theme-meta">
|
<div className="theme-meta">
|
||||||
<p><strong>가격:</strong> {theme.price.toLocaleString()}원</p>
|
<p><strong>1인당 요금:</strong> {theme.price.toLocaleString()}원</p>
|
||||||
<p><strong>난이도:</strong> {getDifficultyText(theme.difficulty)}</p>
|
<p><strong>난이도:</strong> {getDifficultyText(theme.difficulty)}</p>
|
||||||
<p><strong>참여 가능 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
<p><strong>참여 가능 인원:</strong> {theme.minParticipants} ~ {theme.maxParticipants}명</p>
|
||||||
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
<p><strong>예상 소요 시간:</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분</p>
|
||||||
@ -240,11 +255,11 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
{schedules.length > 0 ? schedules.map(schedule => (
|
{schedules.length > 0 ? schedules.map(schedule => (
|
||||||
<div
|
<div
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`}
|
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||||||
onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)}
|
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||||||
>
|
>
|
||||||
{schedule.time}
|
{schedule.time}
|
||||||
<span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span>
|
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
)) : <div className="no-times">선택 가능한 시간이 없습니다.</div>}
|
||||||
</div>
|
</div>
|
||||||
@ -252,7 +267,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
|
|
||||||
<div className="next-step-button-container">
|
<div className="next-step-button-container">
|
||||||
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
|
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
|
||||||
결제하기
|
예약하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -267,7 +282,7 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
<p><strong>난이도:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
|
<p><strong>난이도:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
|
||||||
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
<p><strong>참여 인원:</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명</p>
|
||||||
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
<p><strong>소요 시간:</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분</p>
|
||||||
<p><strong>가격:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
<p><strong>1인당 요금:</strong> {selectedTheme.price.toLocaleString()}원</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-section">
|
<div className="modal-section">
|
||||||
<h3>소개</h3>
|
<h3>소개</h3>
|
||||||
@ -286,11 +301,10 @@ const ReservationStep1PageV21: React.FC = () => {
|
|||||||
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
<p><strong>날짜:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
|
||||||
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
<p><strong>테마:</strong> {selectedTheme!!.name}</p>
|
||||||
<p><strong>시간:</strong> {formatTime(selectedSchedule!!.time)}</p>
|
<p><strong>시간:</strong> {formatTime(selectedSchedule!!.time)}</p>
|
||||||
<p><strong>결제금액:</strong> {selectedTheme!!.price.toLocaleString()}원</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||||||
<button className="confirm-button" onClick={handleConfirmPayment}>결제하기</button>
|
<button className="confirm-button" onClick={handleConfirmReservation}>예약하기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
|
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
|
||||||
|
import { type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
|
|
||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { PaymentType } from '@_api/payment/PaymentTypes';
|
||||||
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
|
|
||||||
import '@_css/reservation-v2.css';
|
import '@_css/reservation-v2.css';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@ -49,7 +51,7 @@ const ReservationStep2Page: React.FC = () => {
|
|||||||
|
|
||||||
const paymentMethods = paymentWidget.renderPaymentMethods(
|
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||||
"#payment-method",
|
"#payment-method",
|
||||||
{ value: 1000 }, // TODO: 테마별 가격 적용
|
{ value: 1000 }, // TODO: 테마별 요금 적용
|
||||||
{ variantKey: "DEFAULT" }
|
{ variantKey: "DEFAULT" }
|
||||||
);
|
);
|
||||||
paymentMethodsRef.current = paymentMethods;
|
paymentMethodsRef.current = paymentMethods;
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { isLoginRequiredError } from '@_api/apiClient';
|
import { isLoginRequiredError } from '@_api/apiClient';
|
||||||
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
|
import { confirmPayment } from '@_api/payment/paymentAPI';
|
||||||
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
|
import { PaymentType, type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
|
||||||
import '@_css/reservation-v2-1.css'; // Reuse the new CSS for consistency
|
import { confirmReservation } from '@_api/reservation/reservationAPIV2';
|
||||||
|
import '@_css/reservation-v2-1.css';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
@ -12,16 +13,14 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This component is designed to work with the state passed from ReservationStep1PageV21
|
|
||||||
const ReservationStep2PageV21: React.FC = () => {
|
const ReservationStep2PageV21: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const paymentWidgetRef = useRef<any>(null);
|
const paymentWidgetRef = useRef<any>(null);
|
||||||
const paymentMethodsRef = useRef<any>(null);
|
const paymentMethodsRef = useRef<any>(null);
|
||||||
|
|
||||||
// The reservation object now contains the price
|
const { reservationId, themeName, date, startAt, price } = location.state || {};
|
||||||
const reservation: ReservationCreateResponse & { price: number } | undefined = location.state?.reservation;
|
|
||||||
console.log(reservation)
|
|
||||||
const handleError = (err: any) => {
|
const handleError = (err: any) => {
|
||||||
if (isLoginRequiredError(err)) {
|
if (isLoginRequiredError(err)) {
|
||||||
alert('로그인이 필요해요.');
|
alert('로그인이 필요해요.');
|
||||||
@ -34,7 +33,7 @@ const ReservationStep2PageV21: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reservation) {
|
if (!reservationId) {
|
||||||
alert('잘못된 접근입니다.');
|
alert('잘못된 접근입니다.');
|
||||||
navigate('/v2-1/reservation');
|
navigate('/v2-1/reservation');
|
||||||
return;
|
return;
|
||||||
@ -52,15 +51,15 @@ const ReservationStep2PageV21: React.FC = () => {
|
|||||||
|
|
||||||
const paymentMethods = paymentWidget.renderPaymentMethods(
|
const paymentMethods = paymentWidget.renderPaymentMethods(
|
||||||
"#payment-method",
|
"#payment-method",
|
||||||
{ value: reservation.price }, // Use the price from the reservation object
|
{ value: price },
|
||||||
{ variantKey: "DEFAULT" }
|
{ variantKey: "DEFAULT" }
|
||||||
);
|
);
|
||||||
paymentMethodsRef.current = paymentMethods;
|
paymentMethodsRef.current = paymentMethods;
|
||||||
};
|
};
|
||||||
}, [reservation, navigate]);
|
}, [reservationId, price, navigate]);
|
||||||
|
|
||||||
const handlePayment = () => {
|
const handlePayment = () => {
|
||||||
if (!paymentWidgetRef.current || !reservation) {
|
if (!paymentWidgetRef.current || !reservationId) {
|
||||||
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
|
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -70,24 +69,27 @@ const ReservationStep2PageV21: React.FC = () => {
|
|||||||
|
|
||||||
paymentWidgetRef.current.requestPayment({
|
paymentWidgetRef.current.requestPayment({
|
||||||
orderId: generateRandomString(),
|
orderId: generateRandomString(),
|
||||||
orderName: `${reservation.themeName} 예약 결제`,
|
orderName: `${themeName} 예약 결제`,
|
||||||
amount: reservation.price,
|
amount: price,
|
||||||
}).then((data: any) => {
|
}).then((data: any) => {
|
||||||
const paymentData: ReservationPaymentRequest = {
|
const paymentData: PaymentConfirmRequest = {
|
||||||
paymentKey: data.paymentKey,
|
paymentKey: data.paymentKey,
|
||||||
orderId: data.orderId,
|
orderId: data.orderId,
|
||||||
amount: data.amount,
|
amount: price, // Use the price from component state instead of widget response
|
||||||
paymentType: data.paymentType || PaymentType.NORMAL,
|
paymentType: data.paymentType || PaymentType.NORMAL,
|
||||||
};
|
};
|
||||||
confirmReservationPayment(reservation.reservationId, paymentData)
|
|
||||||
.then((res) => {
|
confirmPayment(reservationId, paymentData)
|
||||||
// Navigate to the new success page
|
.then(() => {
|
||||||
|
return confirmReservation(reservationId);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
alert('결제가 완료되었어요!');
|
||||||
navigate('/v2-1/reservation/success', {
|
navigate('/v2-1/reservation/success', {
|
||||||
state: {
|
state: {
|
||||||
reservation: res,
|
themeName,
|
||||||
themeName: reservation.themeName,
|
date,
|
||||||
date: reservation.date,
|
startAt,
|
||||||
startAt: reservation.startAt,
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@ -98,22 +100,19 @@ const ReservationStep2PageV21: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservationId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = formatDate(reservation.date)
|
|
||||||
const time = formatTime(reservation.startAt);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reservation-v21-container">
|
<div className="reservation-v21-container">
|
||||||
<h2 className="page-title">결제하기</h2>
|
<h2 className="page-title">결제하기</h2>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>결제 정보 확인</h3>
|
<h3>결제 정보 확인</h3>
|
||||||
<p><strong>테마:</strong> {reservation.themeName}</p>
|
<p><strong>테마:</strong> {themeName}</p>
|
||||||
<p><strong>날짜:</strong> {date}</p>
|
<p><strong>날짜:</strong> {formatDate(date)}</p>
|
||||||
<p><strong>시간:</strong> {time}</p>
|
<p><strong>시간:</strong> {formatTime(startAt)}</p>
|
||||||
<p><strong>금액:</strong> {reservation.price.toLocaleString()}원</p>
|
<p><strong>금액:</strong> {price.toLocaleString()}원</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="step-section">
|
<div className="step-section">
|
||||||
<h3>결제 수단</h3>
|
<h3>결제 수단</h3>
|
||||||
@ -122,7 +121,7 @@ const ReservationStep2PageV21: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="next-step-button-container">
|
<div className="next-step-button-container">
|
||||||
<button onClick={handlePayment} className="next-step-button">
|
<button onClick={handlePayment} className="next-step-button">
|
||||||
{reservation.price.toLocaleString()}원 결제하기
|
{price.toLocaleString()}원 결제하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,25 +1,16 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
|
||||||
import type { ReservationPaymentResponse } from '@_api/reservation/reservationTypes';
|
|
||||||
import '@_css/reservation-v2-1.css'; // Reuse the new CSS
|
import '@_css/reservation-v2-1.css'; // Reuse the new CSS
|
||||||
|
import React from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
|
||||||
|
|
||||||
const ReservationSuccessPageV21: React.FC = () => {
|
const ReservationSuccessPageV21: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const { themeName, date, startAt } = (location.state as {
|
||||||
const { reservation, themeName, date, startAt } = (location.state as {
|
|
||||||
reservation: ReservationPaymentResponse;
|
|
||||||
themeName: string;
|
themeName: string;
|
||||||
date: string;
|
date: string;
|
||||||
startAt: string;
|
startAt: string;
|
||||||
}) || {};
|
}) || {};
|
||||||
|
|
||||||
if (!reservation) {
|
|
||||||
React.useEffect(() => {
|
|
||||||
navigate('/v2-1/reservation'); // Redirect to the new reservation page on error
|
|
||||||
}, [navigate]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const formattedDate = formatDate(date)
|
const formattedDate = formatDate(date)
|
||||||
const formattedTime = formatTime(startAt);
|
const formattedTime = formatTime(startAt);
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ abstract class PersistableBaseEntity(
|
|||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
private var isNewEntity: Boolean = true
|
private var isNewEntity: Boolean = true
|
||||||
): Persistable<Long> {
|
) : Persistable<Long> {
|
||||||
@PostLoad
|
@PostLoad
|
||||||
@PostPersist
|
@PostPersist
|
||||||
fun markNotNew() {
|
fun markNotNew() {
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
package roomescape.common.entity
|
package roomescape.common.entity
|
||||||
|
|
||||||
import jakarta.persistence.Column
|
import jakarta.persistence.*
|
||||||
import jakarta.persistence.EntityListeners
|
|
||||||
import jakarta.persistence.Id
|
|
||||||
import jakarta.persistence.MappedSuperclass
|
|
||||||
import jakarta.persistence.PostLoad
|
|
||||||
import jakarta.persistence.PrePersist
|
|
||||||
import org.springframework.data.annotation.CreatedBy
|
import org.springframework.data.annotation.CreatedBy
|
||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.annotation.LastModifiedBy
|
import org.springframework.data.annotation.LastModifiedBy
|
||||||
@ -13,10 +8,32 @@ import org.springframework.data.annotation.LastModifiedDate
|
|||||||
import org.springframework.data.domain.Persistable
|
import org.springframework.data.domain.Persistable
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import kotlin.jvm.Transient
|
||||||
|
|
||||||
@MappedSuperclass
|
@MappedSuperclass
|
||||||
@EntityListeners(AuditingEntityListener::class)
|
@EntityListeners(AuditingEntityListener::class)
|
||||||
abstract class AuditingBaseEntity(
|
abstract class AuditingBaseEntity(
|
||||||
|
id: Long,
|
||||||
|
) : BaseEntityV2(id) {
|
||||||
|
@Column(updatable = false)
|
||||||
|
@CreatedDate
|
||||||
|
lateinit var createdAt: LocalDateTime
|
||||||
|
|
||||||
|
@Column(updatable = false)
|
||||||
|
@CreatedBy
|
||||||
|
var createdBy: Long = 0L
|
||||||
|
|
||||||
|
@Column
|
||||||
|
@LastModifiedDate
|
||||||
|
lateinit var updatedAt: LocalDateTime
|
||||||
|
|
||||||
|
@Column
|
||||||
|
@LastModifiedBy
|
||||||
|
var updatedBy: Long = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
abstract class BaseEntityV2(
|
||||||
@Id
|
@Id
|
||||||
@Column(name = "id")
|
@Column(name = "id")
|
||||||
private val _id: Long,
|
private val _id: Long,
|
||||||
@ -24,25 +41,6 @@ abstract class AuditingBaseEntity(
|
|||||||
@Transient
|
@Transient
|
||||||
private var isNewEntity: Boolean = true
|
private var isNewEntity: Boolean = true
|
||||||
) : Persistable<Long> {
|
) : Persistable<Long> {
|
||||||
@Column(updatable = false)
|
|
||||||
@CreatedDate
|
|
||||||
lateinit var createdAt: LocalDateTime
|
|
||||||
protected set
|
|
||||||
|
|
||||||
@Column(updatable = false)
|
|
||||||
@CreatedBy
|
|
||||||
var createdBy: Long = 0L
|
|
||||||
protected set
|
|
||||||
|
|
||||||
@Column
|
|
||||||
@LastModifiedDate
|
|
||||||
lateinit var updatedAt: LocalDateTime
|
|
||||||
protected set
|
|
||||||
|
|
||||||
@Column
|
|
||||||
@LastModifiedBy
|
|
||||||
var updatedBy: Long = 0L
|
|
||||||
protected set
|
|
||||||
|
|
||||||
@PostLoad
|
@PostLoad
|
||||||
@PrePersist
|
@PrePersist
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package roomescape.common.exception
|
|||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.slf4j.MDC
|
import org.slf4j.MDC
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@ -14,6 +15,7 @@ import roomescape.common.dto.response.CommonErrorResponse
|
|||||||
import roomescape.common.log.ApiLogMessageConverter
|
import roomescape.common.log.ApiLogMessageConverter
|
||||||
import roomescape.common.log.ConvertResponseMessageRequest
|
import roomescape.common.log.ConvertResponseMessageRequest
|
||||||
import roomescape.common.log.LogType
|
import roomescape.common.log.LogType
|
||||||
|
import roomescape.common.log.getEndpoint
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -22,7 +24,10 @@ class ExceptionControllerAdvice(
|
|||||||
private val messageConverter: ApiLogMessageConverter
|
private val messageConverter: ApiLogMessageConverter
|
||||||
) {
|
) {
|
||||||
@ExceptionHandler(value = [RoomescapeException::class])
|
@ExceptionHandler(value = [RoomescapeException::class])
|
||||||
fun handleRoomException(e: RoomescapeException): ResponseEntity<CommonErrorResponse> {
|
fun handleRoomException(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
e: RoomescapeException
|
||||||
|
): ResponseEntity<CommonErrorResponse> {
|
||||||
val errorCode: ErrorCode = e.errorCode
|
val errorCode: ErrorCode = e.errorCode
|
||||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||||
val errorResponse = CommonErrorResponse(errorCode)
|
val errorResponse = CommonErrorResponse(errorCode)
|
||||||
@ -30,6 +35,7 @@ class ExceptionControllerAdvice(
|
|||||||
val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE
|
val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE
|
||||||
logException(
|
logException(
|
||||||
type = type,
|
type = type,
|
||||||
|
servletRequest = servletRequest,
|
||||||
httpStatus = httpStatus.value(),
|
httpStatus = httpStatus.value(),
|
||||||
errorResponse = errorResponse,
|
errorResponse = errorResponse,
|
||||||
exception = e
|
exception = e
|
||||||
@ -41,7 +47,10 @@ class ExceptionControllerAdvice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(value = [MethodArgumentNotValidException::class, HttpMessageNotReadableException::class])
|
@ExceptionHandler(value = [MethodArgumentNotValidException::class, HttpMessageNotReadableException::class])
|
||||||
fun handleInvalidRequestValueException(e: Exception): ResponseEntity<CommonErrorResponse> {
|
fun handleInvalidRequestValueException(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
e: Exception
|
||||||
|
): ResponseEntity<CommonErrorResponse> {
|
||||||
val message: String = if (e is MethodArgumentNotValidException) {
|
val message: String = if (e is MethodArgumentNotValidException) {
|
||||||
e.bindingResult.allErrors
|
e.bindingResult.allErrors
|
||||||
.mapNotNull { it.defaultMessage }
|
.mapNotNull { it.defaultMessage }
|
||||||
@ -57,6 +66,7 @@ class ExceptionControllerAdvice(
|
|||||||
|
|
||||||
logException(
|
logException(
|
||||||
type = LogType.APPLICATION_FAILURE,
|
type = LogType.APPLICATION_FAILURE,
|
||||||
|
servletRequest = servletRequest,
|
||||||
httpStatus = httpStatus.value(),
|
httpStatus = httpStatus.value(),
|
||||||
errorResponse = errorResponse,
|
errorResponse = errorResponse,
|
||||||
exception = e
|
exception = e
|
||||||
@ -68,7 +78,10 @@ class ExceptionControllerAdvice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(value = [Exception::class])
|
@ExceptionHandler(value = [Exception::class])
|
||||||
fun handleException(e: Exception): ResponseEntity<CommonErrorResponse> {
|
fun handleException(
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
|
e: Exception
|
||||||
|
): ResponseEntity<CommonErrorResponse> {
|
||||||
log.error(e) { "[ExceptionControllerAdvice] Unexpected exception occurred: ${e.message}" }
|
log.error(e) { "[ExceptionControllerAdvice] Unexpected exception occurred: ${e.message}" }
|
||||||
|
|
||||||
val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR
|
val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR
|
||||||
@ -77,6 +90,7 @@ class ExceptionControllerAdvice(
|
|||||||
|
|
||||||
logException(
|
logException(
|
||||||
type = LogType.UNHANDLED_EXCEPTION,
|
type = LogType.UNHANDLED_EXCEPTION,
|
||||||
|
servletRequest = servletRequest,
|
||||||
httpStatus = httpStatus.value(),
|
httpStatus = httpStatus.value(),
|
||||||
errorResponse = errorResponse,
|
errorResponse = errorResponse,
|
||||||
exception = e
|
exception = e
|
||||||
@ -89,12 +103,14 @@ class ExceptionControllerAdvice(
|
|||||||
|
|
||||||
private fun logException(
|
private fun logException(
|
||||||
type: LogType,
|
type: LogType,
|
||||||
|
servletRequest: HttpServletRequest,
|
||||||
httpStatus: Int,
|
httpStatus: Int,
|
||||||
errorResponse: CommonErrorResponse,
|
errorResponse: CommonErrorResponse,
|
||||||
exception: Exception
|
exception: Exception
|
||||||
) {
|
) {
|
||||||
val commonRequest = ConvertResponseMessageRequest(
|
val commonRequest = ConvertResponseMessageRequest(
|
||||||
type = type,
|
type = type,
|
||||||
|
endpoint = servletRequest.getEndpoint(),
|
||||||
httpStatus = httpStatus,
|
httpStatus = httpStatus,
|
||||||
startTime = MDC.get("startTime")?.toLongOrNull(),
|
startTime = MDC.get("startTime")?.toLongOrNull(),
|
||||||
body = errorResponse,
|
body = errorResponse,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ class ApiLogMessageConverter(
|
|||||||
fun convertToResponseMessage(request: ConvertResponseMessageRequest): String {
|
fun convertToResponseMessage(request: ConvertResponseMessageRequest): String {
|
||||||
val payload: MutableMap<String, Any> = mutableMapOf()
|
val payload: MutableMap<String, Any> = mutableMapOf()
|
||||||
payload["type"] = request.type
|
payload["type"] = request.type
|
||||||
|
payload["endpoint"] = request.endpoint
|
||||||
payload["status_code"] = request.httpStatus
|
payload["status_code"] = request.httpStatus
|
||||||
|
|
||||||
MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull()
|
MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull()
|
||||||
@ -75,8 +76,11 @@ class ApiLogMessageConverter(
|
|||||||
|
|
||||||
data class ConvertResponseMessageRequest(
|
data class ConvertResponseMessageRequest(
|
||||||
val type: LogType,
|
val type: LogType,
|
||||||
|
val endpoint: String,
|
||||||
val httpStatus: Int = 200,
|
val httpStatus: Int = 200,
|
||||||
val startTime: Long? = null,
|
val startTime: Long? = null,
|
||||||
val body: Any? = null,
|
val body: Any? = null,
|
||||||
val exception: Exception? = null
|
val exception: Exception? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun HttpServletRequest.getEndpoint(): String = "${this.method} ${this.requestURI}"
|
||||||
|
|||||||
@ -33,28 +33,31 @@ class ControllerLoggingAspect(
|
|||||||
val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis()
|
val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis()
|
||||||
val controllerPayload: Map<String, Any> = parsePayload(joinPoint)
|
val controllerPayload: Map<String, Any> = parsePayload(joinPoint)
|
||||||
|
|
||||||
|
val servletRequest: HttpServletRequest = servletRequest()
|
||||||
|
|
||||||
log.info {
|
log.info {
|
||||||
messageConverter.convertToControllerInvokedMessage(servletRequest(), controllerPayload)
|
messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return joinPoint.proceed()
|
return joinPoint.proceed()
|
||||||
.also { logSuccess(startTime, it) }
|
.also { logSuccess(servletRequest.getEndpoint(), startTime, it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logSuccess(startTime: Long, result: Any) {
|
private fun logSuccess(endpoint: String, startTime: Long, result: Any) {
|
||||||
val responseEntity = result as ResponseEntity<*>
|
val responseEntity = result as ResponseEntity<*>
|
||||||
var convertResponseMessageRequest = ConvertResponseMessageRequest(
|
var convertResponseMessageRequest = ConvertResponseMessageRequest(
|
||||||
type = LogType.CONTROLLER_SUCCESS,
|
type = LogType.CONTROLLER_SUCCESS,
|
||||||
|
endpoint = endpoint,
|
||||||
httpStatus = responseEntity.statusCode.value(),
|
httpStatus = responseEntity.statusCode.value(),
|
||||||
startTime = startTime,
|
startTime = startTime,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (log.isDebugEnabled()) {
|
if (log.isDebugEnabled()) {
|
||||||
convertResponseMessageRequest = convertResponseMessageRequest.copy(
|
convertResponseMessageRequest = convertResponseMessageRequest.copy(
|
||||||
body = responseEntity.body
|
body = responseEntity.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import org.springframework.stereotype.Service
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import roomescape.member.implement.MemberFinder
|
import roomescape.member.implement.MemberFinder
|
||||||
import roomescape.member.implement.MemberWriter
|
import roomescape.member.implement.MemberWriter
|
||||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
|
||||||
import roomescape.member.infrastructure.persistence.Role
|
import roomescape.member.infrastructure.persistence.Role
|
||||||
import roomescape.member.web.*
|
import roomescape.member.web.*
|
||||||
|
|
||||||
@ -26,11 +25,14 @@ class MemberService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findById(memberId: Long): MemberEntity {
|
fun findSummaryById(id: Long): MemberSummaryRetrieveResponse {
|
||||||
log.debug { "[MemberService.findById] 시작" }
|
log.debug { "[MemberService.findSummaryById] 시작" }
|
||||||
|
|
||||||
return memberFinder.findById(memberId)
|
return memberFinder.findById(id)
|
||||||
.also { log.info { "[MemberService.findById] 완료. memberId=${memberId}, email=${it.email}" } }
|
.toSummaryRetrieveResponse()
|
||||||
|
.also {
|
||||||
|
log.info { "[MemberService.findSummaryById] 완료. memberId=${id}, email=${it.email}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@ -22,7 +22,7 @@ class MemberEntity(
|
|||||||
@Column(name = "role", nullable = false, length = 20)
|
@Column(name = "role", nullable = false, length = 20)
|
||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
var role: Role
|
var role: Role
|
||||||
): BaseEntity() {
|
) : BaseEntity() {
|
||||||
override fun getId(): Long? = _id
|
override fun getId(): Long? = _id
|
||||||
|
|
||||||
fun isAdmin(): Boolean = role == Role.ADMIN
|
fun isAdmin(): Boolean = role == Role.ADMIN
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package roomescape.member.web
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema
|
import io.swagger.v3.oas.annotations.media.Schema
|
||||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||||
|
import roomescape.member.infrastructure.persistence.Role
|
||||||
|
|
||||||
fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveResponse(
|
fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveResponse(
|
||||||
id = id!!,
|
id = id!!,
|
||||||
@ -39,3 +40,17 @@ fun MemberEntity.toSignupResponse(): SignupResponse = SignupResponse(
|
|||||||
id = this.id!!,
|
id = this.id!!,
|
||||||
name = this.name
|
name = this.name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MemberSummaryRetrieveResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val role: Role
|
||||||
|
)
|
||||||
|
|
||||||
|
fun MemberEntity.toSummaryRetrieveResponse() = MemberSummaryRetrieveResponse(
|
||||||
|
id = this.id!!,
|
||||||
|
name = this.name,
|
||||||
|
email = this.email,
|
||||||
|
role = this.role
|
||||||
|
)
|
||||||
|
|||||||
@ -1,99 +1,101 @@
|
|||||||
package roomescape.payment.business
|
package roomescape.payment.business
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import roomescape.payment.implement.PaymentFinder
|
import roomescape.common.util.TransactionExecutionUtil
|
||||||
import roomescape.payment.implement.PaymentWriter
|
import roomescape.payment.exception.PaymentErrorCode
|
||||||
import roomescape.payment.infrastructure.client.PaymentApproveResponse
|
import roomescape.payment.exception.PaymentException
|
||||||
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
import roomescape.payment.infrastructure.client.PaymentClientCancelResponse
|
||||||
import roomescape.payment.infrastructure.persistence.PaymentEntity
|
import roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
|
||||||
import roomescape.payment.web.PaymentCancelRequest
|
import roomescape.payment.infrastructure.client.TosspayClient
|
||||||
import roomescape.payment.web.PaymentCancelResponse
|
import roomescape.payment.infrastructure.persistence.*
|
||||||
import roomescape.payment.web.PaymentCreateResponse
|
import roomescape.payment.web.*
|
||||||
import roomescape.payment.web.toCreateResponse
|
|
||||||
import roomescape.reservation.infrastructure.persistence.ReservationEntity
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class PaymentService(
|
class PaymentService(
|
||||||
private val paymentFinder: PaymentFinder,
|
private val paymentClient: TosspayClient,
|
||||||
private val paymentWriter: PaymentWriter
|
private val paymentRepository: PaymentRepository,
|
||||||
|
private val paymentDetailRepository: PaymentDetailRepository,
|
||||||
|
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||||
|
private val paymentWriter: PaymentWriter,
|
||||||
|
private val transactionExecutionUtil: TransactionExecutionUtil,
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
fun confirm(reservationId: Long, request: PaymentConfirmRequest): PaymentCreateResponse {
|
||||||
fun existsByReservationId(reservationId: Long): Boolean {
|
val clientConfirmResponse: PaymentClientConfirmResponse = paymentClient.confirm(
|
||||||
log.debug { "[PaymentService.existsByReservationId] 시작: reservationId=$reservationId" }
|
paymentKey = request.paymentKey,
|
||||||
|
orderId = request.orderId,
|
||||||
return paymentFinder.existsPaymentByReservationId(reservationId)
|
amount = request.amount,
|
||||||
.also { log.info { "[PaymentService.existsByReservationId] 완료: reservationId=$reservationId, isPaid=$it" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun createPayment(
|
|
||||||
approvedPaymentInfo: PaymentApproveResponse,
|
|
||||||
reservation: ReservationEntity,
|
|
||||||
): PaymentCreateResponse {
|
|
||||||
log.debug { "[PaymentService.createPayment] 시작: paymentKey=${approvedPaymentInfo.paymentKey}, reservationId=${reservation.id}" }
|
|
||||||
|
|
||||||
val created: PaymentEntity = paymentWriter.create(
|
|
||||||
paymentKey = approvedPaymentInfo.paymentKey,
|
|
||||||
orderId = approvedPaymentInfo.orderId,
|
|
||||||
totalAmount = approvedPaymentInfo.totalAmount,
|
|
||||||
approvedAt = approvedPaymentInfo.approvedAt,
|
|
||||||
reservation = reservation
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return created.toCreateResponse()
|
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
.also { log.info { "[PaymentService.createPayment] 완료: paymentKey=${it.paymentKey}, reservationId=${reservation.id}, paymentId=${it.id}" } }
|
val payment: PaymentEntity = paymentWriter.createPayment(
|
||||||
}
|
reservationId = reservationId,
|
||||||
|
orderId = request.orderId,
|
||||||
|
paymentType = request.paymentType,
|
||||||
|
paymentClientConfirmResponse = clientConfirmResponse
|
||||||
|
)
|
||||||
|
val detail: PaymentDetailEntity = paymentWriter.createDetail(clientConfirmResponse, payment.id)
|
||||||
|
|
||||||
@Transactional
|
PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
|
||||||
fun createCanceledPayment(
|
|
||||||
canceledPaymentInfo: PaymentCancelResponse,
|
|
||||||
approvedAt: OffsetDateTime,
|
|
||||||
paymentKey: String,
|
|
||||||
): CanceledPaymentEntity {
|
|
||||||
log.debug { "[PaymentService.createCanceledPayment] 시작: paymentKey=$paymentKey" }
|
|
||||||
|
|
||||||
val created: CanceledPaymentEntity = paymentWriter.createCanceled(
|
|
||||||
cancelReason = canceledPaymentInfo.cancelReason,
|
|
||||||
cancelAmount = canceledPaymentInfo.cancelAmount,
|
|
||||||
canceledAt = canceledPaymentInfo.canceledAt,
|
|
||||||
approvedAt = approvedAt,
|
|
||||||
paymentKey = paymentKey
|
|
||||||
)
|
|
||||||
|
|
||||||
return created.also {
|
|
||||||
log.info { "[PaymentService.createCanceledPayment] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
fun cancel(memberId: Long, request: PaymentCancelRequest) {
|
||||||
fun createCanceledPayment(reservationId: Long): PaymentCancelRequest {
|
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
|
||||||
log.debug { "[PaymentService.createCanceledPayment] 시작: reservationId=$reservationId" }
|
|
||||||
|
|
||||||
val payment: PaymentEntity = paymentFinder.findByReservationId(reservationId)
|
val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel(
|
||||||
val canceled: CanceledPaymentEntity = paymentWriter.createCanceled(
|
paymentKey = payment.paymentKey,
|
||||||
payment = payment,
|
amount = payment.totalAmount,
|
||||||
cancelReason = "예약 취소",
|
cancelReason = request.cancelReason
|
||||||
canceledAt = OffsetDateTime.now(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return PaymentCancelRequest(canceled.paymentKey, canceled.cancelAmount, canceled.cancelReason)
|
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
|
||||||
.also { log.info { "[PaymentService.createCanceledPayment] 완료: reservationId=$reservationId, paymentKey=${it.paymentKey}" } }
|
paymentWriter.cancel(
|
||||||
|
memberId = memberId,
|
||||||
|
payment = payment,
|
||||||
|
requestedAt = request.requestedAt,
|
||||||
|
cancelResponse = clientCancelResponse
|
||||||
|
)
|
||||||
|
}.also {
|
||||||
|
log.info { "[PaymentService.cancel] 결제 취소 완료: paymentId=${payment.id}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(readOnly = true)
|
||||||
fun updateCanceledTime(
|
fun findDetailByReservationId(reservationId: Long): PaymentRetrieveResponse {
|
||||||
paymentKey: String,
|
val payment: PaymentEntity = findByReservationIdOrThrow(reservationId)
|
||||||
canceledAt: OffsetDateTime,
|
val paymentDetail: PaymentDetailEntity = findDetailByPaymentIdOrThrow(payment.id)
|
||||||
) {
|
val cancelDetail: CanceledPaymentEntity? = canceledPaymentRepository.findByPaymentId(payment.id)
|
||||||
log.debug { "[PaymentService.updateCanceledTime] 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" }
|
|
||||||
|
|
||||||
paymentFinder.findCanceledByKey(paymentKey).apply { this.canceledAt = canceledAt }
|
return payment.toRetrieveResponse(
|
||||||
|
detail = paymentDetail.toPaymentDetailResponse(),
|
||||||
|
cancel = cancelDetail?.toCancelDetailResponse()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
log.info { "[PaymentService.updateCanceledTime] 완료: paymentKey=$paymentKey, canceledAt=$canceledAt" }
|
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
|
||||||
|
log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
|
||||||
|
|
||||||
|
return paymentRepository.findByReservationId(reservationId)
|
||||||
|
?.also { log.info { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
|
||||||
|
?: run {
|
||||||
|
log.warn { "[PaymentService.findByReservationIdOrThrow] 결제 정보 조회 실패: reservationId=$reservationId" }
|
||||||
|
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findDetailByPaymentIdOrThrow(paymentId: Long): PaymentDetailEntity {
|
||||||
|
log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
|
||||||
|
|
||||||
|
return paymentDetailRepository.findByPaymentId(paymentId)
|
||||||
|
?.also { log.info { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 완료: paymentId=$paymentId, detailId=${it.id}}" } }
|
||||||
|
?: run {
|
||||||
|
log.warn { "[PaymentService.findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 실패: paymentId=$paymentId" }
|
||||||
|
throw PaymentException(PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package roomescape.payment.implement
|
package roomescape.payment.business
|
||||||
|
|
||||||
import com.github.f4b6a3.tsid.TsidFactory
|
import com.github.f4b6a3.tsid.TsidFactory
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
@ -7,39 +7,40 @@ import org.springframework.stereotype.Component
|
|||||||
import roomescape.common.config.next
|
import roomescape.common.config.next
|
||||||
import roomescape.payment.exception.PaymentErrorCode
|
import roomescape.payment.exception.PaymentErrorCode
|
||||||
import roomescape.payment.exception.PaymentException
|
import roomescape.payment.exception.PaymentException
|
||||||
import roomescape.payment.infrastructure.client.v2.*
|
import roomescape.payment.infrastructure.client.*
|
||||||
import roomescape.payment.infrastructure.common.PaymentMethod
|
import roomescape.payment.infrastructure.common.PaymentMethod
|
||||||
import roomescape.payment.infrastructure.persistence.v2.*
|
import roomescape.payment.infrastructure.common.PaymentType
|
||||||
import roomescape.reservation.web.ReservationPaymentRequest
|
import roomescape.payment.infrastructure.persistence.*
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class PaymentWriterV2(
|
class PaymentWriter(
|
||||||
private val paymentRepository: PaymentRepositoryV2,
|
private val paymentRepository: PaymentRepository,
|
||||||
private val paymentDetailRepository: PaymentDetailRepository,
|
private val paymentDetailRepository: PaymentDetailRepository,
|
||||||
private val canceledPaymentRepository: CanceledPaymentRepositoryV2,
|
private val canceledPaymentRepository: CanceledPaymentRepository,
|
||||||
private val tsidFactory: TsidFactory,
|
private val tsidFactory: TsidFactory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun createPayment(
|
fun createPayment(
|
||||||
reservationId: Long,
|
reservationId: Long,
|
||||||
request: ReservationPaymentRequest,
|
orderId: String,
|
||||||
paymentConfirmResponse: PaymentConfirmResponse
|
paymentType: PaymentType,
|
||||||
): PaymentEntityV2 {
|
paymentClientConfirmResponse: PaymentClientConfirmResponse
|
||||||
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${request.paymentKey}" }
|
): PaymentEntity {
|
||||||
|
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentClientConfirmResponse.paymentKey}" }
|
||||||
|
|
||||||
return paymentConfirmResponse.toEntity(
|
return paymentClientConfirmResponse.toEntity(
|
||||||
id = tsidFactory.next(), reservationId, request.orderId, request.paymentType
|
id = tsidFactory.next(), reservationId, orderId, paymentType
|
||||||
).also {
|
).also {
|
||||||
paymentRepository.save(it)
|
paymentRepository.save(it)
|
||||||
createDetail(paymentConfirmResponse, it.id)
|
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
|
||||||
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, paymentId=${it.id}" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDetail(
|
fun createDetail(
|
||||||
paymentResponse: PaymentConfirmResponse,
|
paymentResponse: PaymentClientConfirmResponse,
|
||||||
paymentId: Long,
|
paymentId: Long,
|
||||||
): PaymentDetailEntity {
|
): PaymentDetailEntity {
|
||||||
val method: PaymentMethod = paymentResponse.method
|
val method: PaymentMethod = paymentResponse.method
|
||||||
@ -57,24 +58,24 @@ class PaymentWriterV2(
|
|||||||
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createCanceledPayment(
|
fun cancel(
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
payment: PaymentEntityV2,
|
payment: PaymentEntity,
|
||||||
requestedAt: LocalDateTime,
|
requestedAt: LocalDateTime,
|
||||||
cancelResponse: PaymentCancelResponseV2
|
cancelResponse: PaymentClientCancelResponse
|
||||||
) {
|
): CanceledPaymentEntity {
|
||||||
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 시작: paymentId=${payment.id}, paymentKey=${payment.paymentKey}" }
|
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
|
||||||
|
|
||||||
val canceledPayment: CanceledPaymentEntityV2 = cancelResponse.cancels.toEntity(
|
paymentRepository.save(payment.apply { this.cancel() })
|
||||||
|
|
||||||
|
return cancelResponse.cancels.toEntity(
|
||||||
id = tsidFactory.next(),
|
id = tsidFactory.next(),
|
||||||
paymentId = payment.id,
|
paymentId = payment.id,
|
||||||
cancelRequestedAt = requestedAt,
|
cancelRequestedAt = requestedAt,
|
||||||
canceledBy = memberId
|
canceledBy = memberId
|
||||||
)
|
).also {
|
||||||
|
canceledPaymentRepository.save(it)
|
||||||
canceledPaymentRepository.save(canceledPayment).also {
|
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
|
||||||
payment.cancel()
|
|
||||||
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 완료: paymentId=${payment.id}, canceledPaymentId=${it.id}, paymentKey=${payment.paymentKey}" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
35
src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt
Normal file
35
src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt
Normal 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>>
|
||||||
|
}
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,7 +15,7 @@ import java.util.*
|
|||||||
class PaymentConfig {
|
class PaymentConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun tossPaymentClientBuilder(
|
fun tosspayClientBuilder(
|
||||||
paymentProperties: PaymentProperties,
|
paymentProperties: PaymentProperties,
|
||||||
): RestClient.Builder {
|
): RestClient.Builder {
|
||||||
val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also {
|
val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also {
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -1,22 +1,15 @@
|
|||||||
package roomescape.payment.infrastructure.client.v2
|
package roomescape.payment.infrastructure.client
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonParser
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
import com.fasterxml.jackson.databind.DeserializationContext
|
import com.fasterxml.jackson.databind.DeserializationContext
|
||||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||||
import roomescape.payment.infrastructure.common.PaymentStatus
|
import roomescape.payment.infrastructure.common.PaymentStatus
|
||||||
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
|
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
data class PaymentCancelRequestV2(
|
data class PaymentClientCancelResponse(
|
||||||
val paymentKey: String,
|
|
||||||
val amount: Int,
|
|
||||||
val cancelReason: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentCancelResponseV2(
|
|
||||||
val status: PaymentStatus,
|
val status: PaymentStatus,
|
||||||
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
@JsonDeserialize(using = CancelDetailDeserializer::class)
|
||||||
val cancels: CancelDetail,
|
val cancels: CancelDetail,
|
||||||
@ -36,7 +29,7 @@ fun CancelDetail.toEntity(
|
|||||||
paymentId: Long,
|
paymentId: Long,
|
||||||
canceledBy: Long,
|
canceledBy: Long,
|
||||||
cancelRequestedAt: LocalDateTime
|
cancelRequestedAt: LocalDateTime
|
||||||
) = CanceledPaymentEntityV2(
|
) = CanceledPaymentEntity(
|
||||||
id = id,
|
id = id,
|
||||||
canceledAt = this.canceledAt,
|
canceledAt = this.canceledAt,
|
||||||
requestedAt = cancelRequestedAt,
|
requestedAt = cancelRequestedAt,
|
||||||
@ -49,7 +42,7 @@ fun CancelDetail.toEntity(
|
|||||||
easypayDiscountAmount = this.easyPayDiscountAmount
|
easypayDiscountAmount = this.easyPayDiscountAmount
|
||||||
)
|
)
|
||||||
|
|
||||||
class CancelDetailDeserializer : JsonDeserializer<CancelDetail>() {
|
class CancelDetailDeserializer : com.fasterxml.jackson.databind.JsonDeserializer<CancelDetail>() {
|
||||||
override fun deserialize(
|
override fun deserialize(
|
||||||
p: JsonParser,
|
p: JsonParser,
|
||||||
ctxt: DeserializationContext
|
ctxt: DeserializationContext
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,21 +1,15 @@
|
|||||||
package roomescape.payment.infrastructure.client.v2
|
package roomescape.payment.infrastructure.client
|
||||||
|
|
||||||
import roomescape.payment.exception.PaymentErrorCode
|
import roomescape.payment.exception.PaymentErrorCode
|
||||||
import roomescape.payment.exception.PaymentException
|
import roomescape.payment.exception.PaymentException
|
||||||
import roomescape.payment.infrastructure.common.*
|
import roomescape.payment.infrastructure.common.*
|
||||||
import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity
|
import roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
|
||||||
import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity
|
import roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
|
||||||
import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity
|
import roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
|
||||||
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
|
import roomescape.payment.infrastructure.persistence.PaymentEntity
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
data class PaymentConfirmRequest(
|
data class PaymentClientConfirmResponse(
|
||||||
val paymentKey: String,
|
|
||||||
val orderId: String,
|
|
||||||
val amount: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PaymentConfirmResponse(
|
|
||||||
val paymentKey: String,
|
val paymentKey: String,
|
||||||
val status: PaymentStatus,
|
val status: PaymentStatus,
|
||||||
val totalAmount: Int,
|
val totalAmount: Int,
|
||||||
@ -29,12 +23,12 @@ data class PaymentConfirmResponse(
|
|||||||
val approvedAt: OffsetDateTime,
|
val approvedAt: OffsetDateTime,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun PaymentConfirmResponse.toEntity(
|
fun PaymentClientConfirmResponse.toEntity(
|
||||||
id: Long,
|
id: Long,
|
||||||
reservationId: Long,
|
reservationId: Long,
|
||||||
orderId: String,
|
orderId: String,
|
||||||
paymentType: PaymentType
|
paymentType: PaymentType
|
||||||
) = PaymentEntityV2(
|
) = PaymentEntity(
|
||||||
id = id,
|
id = id,
|
||||||
reservationId = reservationId,
|
reservationId = reservationId,
|
||||||
paymentKey = this.paymentKey,
|
paymentKey = this.paymentKey,
|
||||||
@ -58,7 +52,7 @@ data class CardDetail(
|
|||||||
val installmentPlanMonths: Int
|
val installmentPlanMonths: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
fun PaymentConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
fun PaymentClientConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
|
||||||
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
|
||||||
|
|
||||||
return PaymentCardDetailEntity(
|
return PaymentCardDetailEntity(
|
||||||
@ -85,7 +79,7 @@ data class EasyPayDetail(
|
|||||||
val discountAmount: Int,
|
val discountAmount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun PaymentConfirmResponse.toEasypayPrepaidDetailEntity(
|
fun PaymentClientConfirmResponse.toEasypayPrepaidDetailEntity(
|
||||||
id: Long,
|
id: Long,
|
||||||
paymentId: Long
|
paymentId: Long
|
||||||
): PaymentEasypayPrepaidDetailEntity {
|
): PaymentEasypayPrepaidDetailEntity {
|
||||||
@ -107,7 +101,7 @@ data class TransferDetail(
|
|||||||
val settlementStatus: String,
|
val settlementStatus: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun PaymentConfirmResponse.toTransferDetailEntity(
|
fun PaymentClientConfirmResponse.toTransferDetailEntity(
|
||||||
id: Long,
|
id: Long,
|
||||||
paymentId: Long
|
paymentId: Long
|
||||||
): PaymentBankTransferDetailEntity {
|
): PaymentBankTransferDetailEntity {
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package roomescape.payment.infrastructure.client
|
||||||
|
|
||||||
|
data class TosspayErrorResponse(
|
||||||
|
val code: String,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -23,9 +23,9 @@ enum class PaymentType(
|
|||||||
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
|
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
|
||||||
fun get(name: String): PaymentType {
|
fun get(name: String): PaymentType {
|
||||||
return CACHE[name.uppercase()] ?: run {
|
return CACHE[name.uppercase()] ?: run {
|
||||||
log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$name" }
|
log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$name" }
|
||||||
throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND)
|
throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -163,9 +163,9 @@ enum class BankCode(
|
|||||||
val parsedCode = if (code.length == 2) "0$code" else code
|
val parsedCode = if (code.length == 2) "0$code" else code
|
||||||
|
|
||||||
return CACHE[parsedCode] ?: run {
|
return CACHE[parsedCode] ?: run {
|
||||||
log.error { "[PaymentCode.BankCode] 은행 코드 조회 실패: code=$code" }
|
log.error { "[PaymentCode.BankCode] 은행 코드 조회 실패: code=$code" }
|
||||||
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
|
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -207,9 +207,9 @@ enum class CardIssuerCode(
|
|||||||
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
|
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
|
||||||
fun get(code: String): CardIssuerCode {
|
fun get(code: String): CardIssuerCode {
|
||||||
return CACHE[code] ?: run {
|
return CACHE[code] ?: run {
|
||||||
log.error { "[PaymentCode.CardIssuerCode] 카드사 코드 조회 실패: code=$code" }
|
log.error { "[PaymentCode.CardIssuerCode] 카드사 코드 조회 실패: code=$code" }
|
||||||
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
|
throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,24 @@
|
|||||||
package roomescape.payment.infrastructure.persistence
|
package roomescape.payment.infrastructure.persistence
|
||||||
|
|
||||||
import jakarta.persistence.Column
|
|
||||||
import jakarta.persistence.Entity
|
import jakarta.persistence.Entity
|
||||||
import jakarta.persistence.Id
|
|
||||||
import jakarta.persistence.Table
|
import jakarta.persistence.Table
|
||||||
import roomescape.common.entity.BaseEntity
|
import roomescape.common.entity.PersistableBaseEntity
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "canceled_payments")
|
@Table(name = "canceled_payment")
|
||||||
class CanceledPaymentEntity(
|
class CanceledPaymentEntity(
|
||||||
@Id
|
id: Long,
|
||||||
@Column(name = "canceled_payment_id")
|
|
||||||
private var _id: Long?,
|
|
||||||
|
|
||||||
@Column(name = "payment_key", nullable = false)
|
val paymentId: Long,
|
||||||
var paymentKey: String,
|
val requestedAt: LocalDateTime,
|
||||||
|
val canceledAt: OffsetDateTime,
|
||||||
|
val canceledBy: Long,
|
||||||
|
val cancelReason: String,
|
||||||
|
val cancelAmount: Int,
|
||||||
|
val cardDiscountAmount: Int,
|
||||||
|
val transferDiscountAmount: Int,
|
||||||
|
val easypayDiscountAmount: Int,
|
||||||
|
) : PersistableBaseEntity(id)
|
||||||
|
|
||||||
@Column(name = "cancel_reason", nullable = false)
|
|
||||||
var cancelReason: String,
|
|
||||||
|
|
||||||
@Column(name = "cancel_amount", nullable = false)
|
|
||||||
var cancelAmount: Long,
|
|
||||||
|
|
||||||
@Column(name = "approved_at", nullable = false)
|
|
||||||
var approvedAt: OffsetDateTime,
|
|
||||||
|
|
||||||
@Column(name = "canceled_at", nullable = false)
|
|
||||||
var canceledAt: OffsetDateTime,
|
|
||||||
): BaseEntity() {
|
|
||||||
override fun getId(): Long? = _id
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,5 +3,5 @@ package roomescape.payment.infrastructure.persistence
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
interface CanceledPaymentRepository : JpaRepository<CanceledPaymentEntity, Long> {
|
interface CanceledPaymentRepository : JpaRepository<CanceledPaymentEntity, Long> {
|
||||||
fun findByPaymentKey(paymentKey: String): CanceledPaymentEntity?
|
fun findByPaymentId(paymentId: Long): CanceledPaymentEntity?
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
package roomescape.payment.infrastructure.persistence.v2
|
package roomescape.payment.infrastructure.persistence
|
||||||
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import roomescape.common.entity.PersistableBaseEntity
|
import roomescape.common.entity.PersistableBaseEntity
|
||||||
import roomescape.payment.infrastructure.common.*
|
import roomescape.payment.infrastructure.common.*
|
||||||
import kotlin.jvm.Transient
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "payment_detail")
|
@Table(name = "payment_detail")
|
||||||
@ -14,9 +13,6 @@ open class PaymentDetailEntity(
|
|||||||
open val paymentId: Long,
|
open val paymentId: Long,
|
||||||
open val suppliedAmount: Int,
|
open val suppliedAmount: Int,
|
||||||
open val vat: Int,
|
open val vat: Int,
|
||||||
|
|
||||||
@Transient
|
|
||||||
private var isNewEntity: Boolean = true
|
|
||||||
) : PersistableBaseEntity(id)
|
) : PersistableBaseEntity(id)
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@ -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?
|
||||||
|
}
|
||||||
@ -1,32 +1,38 @@
|
|||||||
package roomescape.payment.infrastructure.persistence
|
package roomescape.payment.infrastructure.persistence
|
||||||
|
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.Entity
|
||||||
import roomescape.common.entity.BaseEntity
|
import jakarta.persistence.EnumType
|
||||||
import roomescape.reservation.infrastructure.persistence.ReservationEntity
|
import jakarta.persistence.Enumerated
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import roomescape.common.entity.PersistableBaseEntity
|
||||||
|
import roomescape.payment.infrastructure.common.PaymentMethod
|
||||||
|
import roomescape.payment.infrastructure.common.PaymentStatus
|
||||||
|
import roomescape.payment.infrastructure.common.PaymentType
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "payments")
|
@Table(name = "payment")
|
||||||
class PaymentEntity(
|
class PaymentEntity(
|
||||||
@Id
|
id: Long,
|
||||||
@Column(name = "payment_id")
|
|
||||||
private var _id: Long?,
|
|
||||||
|
|
||||||
@Column(name = "order_id", nullable = false)
|
val reservationId: Long,
|
||||||
var orderId: String,
|
val paymentKey: String,
|
||||||
|
val orderId: String,
|
||||||
|
val totalAmount: Int,
|
||||||
|
val requestedAt: OffsetDateTime,
|
||||||
|
val approvedAt: OffsetDateTime,
|
||||||
|
|
||||||
@Column(name="payment_key", nullable = false)
|
@Enumerated(EnumType.STRING)
|
||||||
var paymentKey: String,
|
val type: PaymentType,
|
||||||
|
|
||||||
@Column(name="total_amount", nullable = false)
|
@Enumerated(EnumType.STRING)
|
||||||
var totalAmount: Long,
|
val method: PaymentMethod,
|
||||||
|
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@Enumerated(EnumType.STRING)
|
||||||
@JoinColumn(name = "reservation_id", nullable = false)
|
var status: PaymentStatus
|
||||||
var reservation: ReservationEntity,
|
) : PersistableBaseEntity(id) {
|
||||||
|
|
||||||
@Column(name="approved_at", nullable = false)
|
fun cancel() {
|
||||||
var approvedAt: OffsetDateTime
|
this.status = PaymentStatus.CANCELED
|
||||||
): BaseEntity() {
|
}
|
||||||
override fun getId(): Long? = _id
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
|
|
||||||
interface PaymentRepository : JpaRepository<PaymentEntity, Long> {
|
interface PaymentRepository : JpaRepository<PaymentEntity, Long> {
|
||||||
|
|
||||||
fun existsByReservationId(reservationId: Long): Boolean
|
|
||||||
|
|
||||||
fun findByReservationId(reservationId: Long): PaymentEntity?
|
fun findByReservationId(reservationId: Long): PaymentEntity?
|
||||||
|
|
||||||
fun deleteByPaymentKey(paymentKey: String)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|
||||||
@ -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?
|
|
||||||
}
|
|
||||||
@ -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?
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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?
|
|
||||||
}
|
|
||||||
39
src/main/kotlin/roomescape/payment/web/PaymentController.kt
Normal file
39
src/main/kotlin/roomescape/payment/web/PaymentController.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,38 +1,136 @@
|
|||||||
package roomescape.payment.web
|
package roomescape.payment.web
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
import roomescape.payment.exception.PaymentErrorCode
|
||||||
import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer
|
import roomescape.payment.exception.PaymentException
|
||||||
import roomescape.payment.infrastructure.persistence.PaymentEntity
|
import roomescape.payment.infrastructure.common.PaymentStatus
|
||||||
|
import roomescape.payment.infrastructure.common.PaymentType
|
||||||
|
import roomescape.payment.infrastructure.persistence.*
|
||||||
|
import roomescape.payment.web.PaymentDetailResponse.*
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
data class PaymentCancelRequest(
|
data class PaymentConfirmRequest(
|
||||||
val paymentKey: String,
|
val paymentKey: String,
|
||||||
val amount: Long,
|
val orderId: String,
|
||||||
val cancelReason: String
|
val amount: Int,
|
||||||
)
|
val paymentType: PaymentType
|
||||||
|
|
||||||
@JsonDeserialize(using = PaymentCancelResponseDeserializer::class)
|
|
||||||
data class PaymentCancelResponse(
|
|
||||||
val cancelStatus: String,
|
|
||||||
val cancelReason: String,
|
|
||||||
val cancelAmount: Long,
|
|
||||||
val canceledAt: OffsetDateTime
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PaymentCreateResponse(
|
data class PaymentCreateResponse(
|
||||||
val id: Long,
|
val paymentId: Long,
|
||||||
val orderId: String,
|
val detailId: Long
|
||||||
val paymentKey: String,
|
|
||||||
val totalAmount: Long,
|
|
||||||
val reservationId: Long,
|
|
||||||
val approvedAt: OffsetDateTime
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun PaymentEntity.toCreateResponse() = PaymentCreateResponse(
|
data class PaymentCancelRequest(
|
||||||
id = this.id!!,
|
val reservationId: Long,
|
||||||
orderId = this.orderId,
|
val cancelReason: String,
|
||||||
paymentKey = this.paymentKey,
|
val requestedAt: LocalDateTime = LocalDateTime.now()
|
||||||
totalAmount = this.totalAmount,
|
|
||||||
reservationId = this.reservation.id!!,
|
|
||||||
approvedAt = this.approvedAt
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PaymentRetrieveResponse(
|
||||||
|
val orderId: String,
|
||||||
|
val totalAmount: Int,
|
||||||
|
val method: String,
|
||||||
|
val status: PaymentStatus,
|
||||||
|
val requestedAt: OffsetDateTime,
|
||||||
|
val approvedAt: OffsetDateTime,
|
||||||
|
val detail: PaymentDetailResponse,
|
||||||
|
val cancel: PaymentCancelDetailResponse?,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PaymentEntity.toRetrieveResponse(
|
||||||
|
detail: PaymentDetailResponse,
|
||||||
|
cancel: PaymentCancelDetailResponse?
|
||||||
|
): PaymentRetrieveResponse {
|
||||||
|
return PaymentRetrieveResponse(
|
||||||
|
orderId = this.orderId,
|
||||||
|
totalAmount = this.totalAmount,
|
||||||
|
method = this.method.koreanName,
|
||||||
|
status = this.status,
|
||||||
|
requestedAt = this.requestedAt,
|
||||||
|
approvedAt = this.approvedAt,
|
||||||
|
detail = detail,
|
||||||
|
cancel = cancel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class PaymentDetailResponse {
|
||||||
|
|
||||||
|
data class CardDetailResponse(
|
||||||
|
val type: String = "CARD",
|
||||||
|
val issuerCode: String,
|
||||||
|
val cardType: String,
|
||||||
|
val ownerType: String,
|
||||||
|
val cardNumber: String,
|
||||||
|
val amount: Int,
|
||||||
|
val approvalNumber: String,
|
||||||
|
val installmentPlanMonths: Int,
|
||||||
|
val easypayProviderName: String?,
|
||||||
|
val easypayDiscountAmount: Int?,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
|
||||||
|
data class BankTransferDetailResponse(
|
||||||
|
val type: String = "BANK_TRANSFER",
|
||||||
|
val bankName: String,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
|
||||||
|
data class EasyPayPrepaidDetailResponse(
|
||||||
|
val type: String = "EASYPAY_PREPAID",
|
||||||
|
val providerName: String,
|
||||||
|
val amount: Int,
|
||||||
|
val discountAmount: Int,
|
||||||
|
) : PaymentDetailResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentDetailEntity.toPaymentDetailResponse(): PaymentDetailResponse {
|
||||||
|
return when (this) {
|
||||||
|
is PaymentCardDetailEntity -> this.toCardDetailResponse()
|
||||||
|
is PaymentBankTransferDetailEntity -> this.toBankTransferDetailResponse()
|
||||||
|
is PaymentEasypayPrepaidDetailEntity -> this.toEasyPayPrepaidDetailResponse()
|
||||||
|
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentCardDetailEntity.toCardDetailResponse(): CardDetailResponse {
|
||||||
|
return CardDetailResponse(
|
||||||
|
issuerCode = this.issuerCode.koreanName,
|
||||||
|
cardType = this.cardType.koreanName,
|
||||||
|
ownerType = this.ownerType.koreanName,
|
||||||
|
cardNumber = this.cardNumber,
|
||||||
|
amount = this.amount,
|
||||||
|
approvalNumber = this.approvalNumber,
|
||||||
|
installmentPlanMonths = this.installmentPlanMonths,
|
||||||
|
easypayProviderName = this.easypayProviderCode?.koreanName,
|
||||||
|
easypayDiscountAmount = this.easypayDiscountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentBankTransferDetailEntity.toBankTransferDetailResponse(): BankTransferDetailResponse {
|
||||||
|
return BankTransferDetailResponse(
|
||||||
|
bankName = this.bankCode.koreanName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayPrepaidDetailResponse {
|
||||||
|
return EasyPayPrepaidDetailResponse(
|
||||||
|
providerName = this.easypayProviderCode.koreanName,
|
||||||
|
amount = this.amount,
|
||||||
|
discountAmount = this.discountAmount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PaymentCancelDetailResponse(
|
||||||
|
val cancellationRequestedAt: LocalDateTime,
|
||||||
|
val cancellationApprovedAt: OffsetDateTime?,
|
||||||
|
val cancelReason: String,
|
||||||
|
val canceledBy: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun CanceledPaymentEntity.toCancelDetailResponse(): PaymentCancelDetailResponse {
|
||||||
|
return PaymentCancelDetailResponse(
|
||||||
|
cancellationRequestedAt = this.requestedAt,
|
||||||
|
cancellationApprovedAt = this.canceledAt,
|
||||||
|
cancelReason = this.cancelReason,
|
||||||
|
canceledBy = this.canceledBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>>
|
|
||||||
}
|
|
||||||
@ -2,150 +2,54 @@ package roomescape.reservation.docs
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
import io.swagger.v3.oas.annotations.Parameter
|
import io.swagger.v3.oas.annotations.Parameter
|
||||||
import io.swagger.v3.oas.annotations.headers.Header
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema
|
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag
|
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import roomescape.auth.web.support.Admin
|
|
||||||
import roomescape.auth.web.support.LoginRequired
|
import roomescape.auth.web.support.LoginRequired
|
||||||
import roomescape.auth.web.support.MemberId
|
import roomescape.auth.web.support.MemberId
|
||||||
import roomescape.common.dto.response.CommonApiResponse
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
import roomescape.reservation.web.*
|
import roomescape.reservation.web.*
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
@Tag(name = "3. 예약 API", description = "예약 및 대기 정보를 추가 / 조회 / 삭제할 때 사용합니다.")
|
|
||||||
interface ReservationAPI {
|
interface ReservationAPI {
|
||||||
|
|
||||||
@Admin
|
@LoginRequired
|
||||||
@Operation(summary = "모든 예약 정보 조회", tags = ["관리자 로그인이 필요한 API"])
|
@Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"])
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>>
|
fun createPendingReservation(
|
||||||
|
@MemberId @Parameter(hidden = true) memberId: Long,
|
||||||
|
@Valid @RequestBody request: PendingReservationCreateRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>>
|
||||||
|
|
||||||
@LoginRequired
|
@LoginRequired
|
||||||
@Operation(summary = "자신의 예약 및 대기 조회", tags = ["로그인이 필요한 API"])
|
@Operation(summary = "예약 확정", tags = ["로그인이 필요한 API"])
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
fun findReservationsByMemberId(
|
fun confirmReservation(
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long
|
@PathVariable("id") id: Long
|
||||||
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>>
|
|
||||||
|
|
||||||
@Admin
|
|
||||||
@Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = ["관리자 로그인이 필요한 API"])
|
|
||||||
@ApiResponses(
|
|
||||||
ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
|
||||||
)
|
|
||||||
fun searchReservations(
|
|
||||||
@RequestParam(required = false) themeId: Long?,
|
|
||||||
@RequestParam(required = false) memberId: Long?,
|
|
||||||
@RequestParam(required = false) dateFrom: LocalDate?,
|
|
||||||
@RequestParam(required = false) dateTo: LocalDate?
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>>
|
|
||||||
|
|
||||||
@Admin
|
|
||||||
@Operation(summary = "관리자의 예약 취소", tags = ["관리자 로그인이 필요한 API"])
|
|
||||||
@ApiResponses(
|
|
||||||
ApiResponse(responseCode = "204", description = "성공"),
|
|
||||||
)
|
|
||||||
fun cancelReservationByAdmin(
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>>
|
): ResponseEntity<CommonApiResponse<Unit>>
|
||||||
|
|
||||||
@LoginRequired
|
@LoginRequired
|
||||||
@Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"])
|
@Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"])
|
||||||
@ApiResponses(
|
|
||||||
ApiResponse(
|
|
||||||
responseCode = "201",
|
|
||||||
description = "성공",
|
|
||||||
useReturnTypeSchema = true,
|
|
||||||
headers = [Header(
|
|
||||||
name = HttpHeaders.LOCATION,
|
|
||||||
description = "생성된 예약 정보 URL",
|
|
||||||
schema = Schema(example = "/reservations/1")
|
|
||||||
)]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
fun createReservationWithPayment(
|
|
||||||
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
|
|
||||||
|
|
||||||
@Admin
|
|
||||||
@Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"])
|
|
||||||
@ApiResponses(
|
|
||||||
ApiResponse(
|
|
||||||
responseCode = "201",
|
|
||||||
description = "성공",
|
|
||||||
useReturnTypeSchema = true,
|
|
||||||
headers = [Header(
|
|
||||||
name = HttpHeaders.LOCATION,
|
|
||||||
description = "생성된 예약 정보 URL",
|
|
||||||
schema = Schema(example = "/reservations/1")
|
|
||||||
)],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
fun createReservationByAdmin(
|
|
||||||
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
|
|
||||||
|
|
||||||
@Admin
|
|
||||||
@Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"])
|
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>>
|
fun cancelReservation(
|
||||||
|
@MemberId @Parameter(hidden = true) memberId: Long,
|
||||||
|
@PathVariable reservationId: Long,
|
||||||
|
@Valid @RequestBody request: ReservationCancelRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>>
|
||||||
|
|
||||||
@LoginRequired
|
@LoginRequired
|
||||||
@Operation(summary = "예약 대기 신청", tags = ["로그인이 필요한 API"])
|
@Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"])
|
||||||
@ApiResponses(
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
ApiResponse(
|
fun findSummaryByMemberId(
|
||||||
responseCode = "201",
|
@MemberId @Parameter(hidden = true) memberId: Long
|
||||||
description = "성공",
|
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>>
|
||||||
useReturnTypeSchema = true,
|
|
||||||
headers = [Header(
|
|
||||||
name = HttpHeaders.LOCATION,
|
|
||||||
description = "생성된 예약 정보 URL",
|
|
||||||
schema = Schema(example = "/reservations/1")
|
|
||||||
)]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
fun createWaiting(
|
|
||||||
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
|
|
||||||
|
|
||||||
@LoginRequired
|
@LoginRequired
|
||||||
@Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"])
|
@Operation(summary = "특정 예약에 대한 상세 조회", tags = ["로그인이 필요한 API"])
|
||||||
@ApiResponses(
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
ApiResponse(responseCode = "204", description = "성공"),
|
fun findDetailById(
|
||||||
)
|
@PathVariable("id") id: Long
|
||||||
fun cancelWaitingByMember(
|
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>>
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>>
|
|
||||||
|
|
||||||
@Admin
|
|
||||||
@Operation(summary = "대기 중인 예약 승인", tags = ["관리자 로그인이 필요한 API"])
|
|
||||||
@ApiResponses(
|
|
||||||
ApiResponse(responseCode = "200", description = "성공"),
|
|
||||||
)
|
|
||||||
fun confirmWaiting(
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>>
|
|
||||||
|
|
||||||
@Admin
|
|
||||||
@Operation(summary = "대기 중인 예약 거절", tags = ["관리자 로그인이 필요한 API"])
|
|
||||||
@ApiResponses(
|
|
||||||
ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"),
|
|
||||||
)
|
|
||||||
fun rejectWaiting(
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") @Parameter(description = "예약 ID") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>>
|
|
||||||
}
|
|
||||||
@ -9,14 +9,9 @@ enum class ReservationErrorCode(
|
|||||||
override val message: String
|
override val message: String
|
||||||
) : ErrorCode {
|
) : ErrorCode {
|
||||||
RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."),
|
RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."),
|
||||||
RESERVATION_DUPLICATED(HttpStatus.BAD_REQUEST, "R002", "이미 같은 예약이 있어요."),
|
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
|
||||||
ALREADY_RESERVE(HttpStatus.BAD_REQUEST, "R003", "같은 날짜, 시간, 테마에 대한 예약(대기)는 한 번만 가능해요."),
|
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
|
||||||
ALREADY_CONFIRMED(HttpStatus.CONFLICT, "R004", "이미 확정된 예약이에요"),
|
SCHEDULE_NOT_HOLD(HttpStatus.BAD_REQUEST, "R004", "이미 예약되었거나 예약이 불가능한 일정이에요."),
|
||||||
CONFIRMED_RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "R005", "이미 확정된 예약이 있어서 승인할 수 없어요."),
|
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.")
|
||||||
PAST_REQUEST_DATETIME(HttpStatus.BAD_REQUEST, "R005", "과거 시간으로 예약할 수 없어요."),
|
|
||||||
NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."),
|
|
||||||
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."),
|
|
||||||
NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."),
|
|
||||||
RESERVATION_NOT_PENDING(HttpStatus.BAD_REQUEST, "R009", "결제 대기 중인 예약이 아니에요."),
|
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package roomescape.reservation.infrastructure.persistence
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface CanceledReservationRepository : JpaRepository<CanceledReservationEntity, Long>
|
||||||
@ -1,68 +1,35 @@
|
|||||||
package roomescape.reservation.infrastructure.persistence
|
package roomescape.reservation.infrastructure.persistence
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import jakarta.persistence.Entity
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.EnumType
|
||||||
import roomescape.common.entity.BaseEntity
|
import jakarta.persistence.Enumerated
|
||||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
import jakarta.persistence.Table
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
import roomescape.common.entity.AuditingBaseEntity
|
||||||
import roomescape.time.infrastructure.persistence.TimeEntity
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "reservations")
|
@Table(name = "reservation")
|
||||||
class ReservationEntity(
|
class ReservationEntity(
|
||||||
@Id
|
id: Long,
|
||||||
@Column(name = "reservation_id")
|
|
||||||
private var _id: Long?,
|
|
||||||
|
|
||||||
@Column(name = "date", nullable = false)
|
val memberId: Long,
|
||||||
var date: LocalDate,
|
val scheduleId: Long,
|
||||||
|
val reserverName: String,
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
val reserverContact: String,
|
||||||
@JoinColumn(name = "time_id", nullable = false)
|
val participantCount: Short,
|
||||||
var time: TimeEntity,
|
val requirement: String,
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "theme_id", nullable = false)
|
|
||||||
var theme: ThemeEntity,
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
|
||||||
var member: MemberEntity,
|
|
||||||
|
|
||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
@Column(name = "status", nullable = false, length = 30)
|
|
||||||
var status: ReservationStatus,
|
var status: ReservationStatus,
|
||||||
): BaseEntity() {
|
) : AuditingBaseEntity(id) {
|
||||||
override fun getId(): Long? = _id
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun isWaiting(): Boolean = status == ReservationStatus.WAITING
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun isReservedBy(memberId: Long): Boolean {
|
|
||||||
return this.member.id == memberId
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelByUser() {
|
|
||||||
this.status = ReservationStatus.CANCELED_BY_USER
|
|
||||||
}
|
|
||||||
|
|
||||||
fun confirm() {
|
fun confirm() {
|
||||||
this.status = ReservationStatus.CONFIRMED
|
this.status = ReservationStatus.CONFIRMED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
this.status = ReservationStatus.CANCELED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ReservationStatus {
|
enum class ReservationStatus {
|
||||||
CONFIRMED,
|
PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED
|
||||||
CONFIRMED_PAYMENT_REQUIRED,
|
|
||||||
PENDING,
|
|
||||||
WAITING,
|
|
||||||
CANCELED_BY_USER,
|
|
||||||
AUTOMATICALLY_CANCELED,
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun confirmedStatus(): Array<ReservationStatus> = arrayOf(CONFIRMED, CONFIRMED_PAYMENT_REQUIRED)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,75 +1,8 @@
|
|||||||
package roomescape.reservation.infrastructure.persistence
|
package roomescape.reservation.infrastructure.persistence
|
||||||
|
|
||||||
import org.springframework.data.jpa.domain.Specification
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
|
|
||||||
import org.springframework.data.jpa.repository.Modifying
|
|
||||||
import org.springframework.data.jpa.repository.Query
|
|
||||||
import org.springframework.data.repository.query.Param
|
|
||||||
import roomescape.reservation.web.MyReservationRetrieveResponse
|
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
|
||||||
import roomescape.time.infrastructure.persistence.TimeEntity
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
interface ReservationRepository
|
interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
||||||
: JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> {
|
|
||||||
fun findAllByTime(time: TimeEntity): List<ReservationEntity>
|
|
||||||
fun existsByTime(time: TimeEntity): Boolean
|
|
||||||
|
|
||||||
fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity>
|
fun findAllByMemberId(memberId: Long): List<ReservationEntity>
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
UPDATE ReservationEntity r
|
|
||||||
SET r.status = :status
|
|
||||||
WHERE r._id = :_id
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun updateStatusByReservationId(
|
|
||||||
@Param(value = "_id") reservationId: Long,
|
|
||||||
@Param(value = "status") statusForChange: ReservationStatus
|
|
||||||
): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM ReservationEntity r2
|
|
||||||
WHERE r2._id = :_id
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM ReservationEntity r
|
|
||||||
WHERE r.theme._id = r2.theme._id
|
|
||||||
AND r.time._id = r2.time._id
|
|
||||||
AND r.date = r2.date
|
|
||||||
AND r.status != 'WAITING'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun isExistConfirmedReservation(@Param("_id") reservationId: Long): Boolean
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT new roomescape.reservation.web.MyReservationRetrieveResponse(
|
|
||||||
r._id,
|
|
||||||
t.name,
|
|
||||||
r.date,
|
|
||||||
r.time.startAt,
|
|
||||||
r.status,
|
|
||||||
(SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2._id < r._id),
|
|
||||||
p.paymentKey,
|
|
||||||
p.totalAmount
|
|
||||||
)
|
|
||||||
FROM ReservationEntity r
|
|
||||||
JOIN r.theme t
|
|
||||||
LEFT JOIN PaymentEntity p
|
|
||||||
ON p.reservation = r
|
|
||||||
WHERE r.member._id = :memberId
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse>
|
|
||||||
fun findAllByDateAndTheme(date: LocalDate, theme: ThemeEntity): List<ReservationEntity>
|
|
||||||
|
|
||||||
fun findAllByMember_Id(memberId: Long): List<ReservationEntity>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -6,170 +6,59 @@ import org.springframework.http.ResponseEntity
|
|||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import roomescape.auth.web.support.MemberId
|
import roomescape.auth.web.support.MemberId
|
||||||
import roomescape.common.dto.response.CommonApiResponse
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
import roomescape.payment.infrastructure.client.PaymentApproveRequest
|
import roomescape.reservation.business.ReservationService
|
||||||
import roomescape.payment.infrastructure.client.PaymentApproveResponse
|
|
||||||
import roomescape.payment.infrastructure.client.TossPaymentClient
|
|
||||||
import roomescape.payment.web.PaymentCancelRequest
|
|
||||||
import roomescape.reservation.business.ReservationWriteService
|
|
||||||
import roomescape.reservation.business.ReservationFindService
|
|
||||||
import roomescape.reservation.business.ReservationWithPaymentService
|
|
||||||
import roomescape.reservation.docs.ReservationAPI
|
import roomescape.reservation.docs.ReservationAPI
|
||||||
import java.net.URI
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
class ReservationController(
|
class ReservationController(
|
||||||
private val reservationWithPaymentService: ReservationWithPaymentService,
|
private val reservationService: ReservationService
|
||||||
private val reservationFindService: ReservationFindService,
|
|
||||||
private val reservationWriteService: ReservationWriteService,
|
|
||||||
private val paymentClient: TossPaymentClient
|
|
||||||
) : ReservationAPI {
|
) : ReservationAPI {
|
||||||
@GetMapping("/reservations")
|
|
||||||
override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
|
@PostMapping("/reservations/pending")
|
||||||
val response: ReservationRetrieveListResponse = reservationFindService.findReservations()
|
override fun createPendingReservation(
|
||||||
|
@MemberId @Parameter(hidden = true) memberId: Long,
|
||||||
|
@Valid @RequestBody request: PendingReservationCreateRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<PendingReservationCreateResponse>> {
|
||||||
|
val response = reservationService.createPendingReservation(memberId, request)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/reservations-mine")
|
@PostMapping("/reservations/{id}/confirm")
|
||||||
override fun findReservationsByMemberId(
|
override fun confirmReservation(
|
||||||
|
@PathVariable("id") id: Long
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
|
reservationService.confirmReservation(id)
|
||||||
|
|
||||||
|
return ResponseEntity.ok().body(CommonApiResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reservations/{reservationId}/cancel")
|
||||||
|
override fun cancelReservation(
|
||||||
|
@MemberId @Parameter(hidden = true) memberId: Long,
|
||||||
|
@PathVariable reservationId: Long,
|
||||||
|
@Valid @RequestBody request: ReservationCancelRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
|
reservationService.cancelReservation(memberId, reservationId, request)
|
||||||
|
|
||||||
|
return ResponseEntity.ok().body(CommonApiResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/reservations/summary")
|
||||||
|
override fun findSummaryByMemberId(
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long
|
@MemberId @Parameter(hidden = true) memberId: Long
|
||||||
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> {
|
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>> {
|
||||||
val response: MyReservationRetrieveListResponse = reservationFindService.findReservationsByMemberId(memberId)
|
val response = reservationService.findSummaryByMemberId(memberId)
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/reservations/search")
|
@GetMapping("/reservations/{id}/detail")
|
||||||
override fun searchReservations(
|
override fun findDetailById(
|
||||||
@RequestParam(required = false) themeId: Long?,
|
@PathVariable("id") id: Long
|
||||||
@RequestParam(required = false) memberId: Long?,
|
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>> {
|
||||||
@RequestParam(required = false) dateFrom: LocalDate?,
|
val response = reservationService.findDetailById(id)
|
||||||
@RequestParam(required = false) dateTo: LocalDate?
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
|
|
||||||
val response: ReservationRetrieveListResponse = reservationFindService.searchReservations(
|
|
||||||
themeId,
|
|
||||||
memberId,
|
|
||||||
dateFrom,
|
|
||||||
dateTo
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/reservations/{id}")
|
|
||||||
override fun cancelReservationByAdmin(
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
|
||||||
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
|
|
||||||
reservationWriteService.deleteReservation(reservationId, memberId)
|
|
||||||
return ResponseEntity.noContent().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
val paymentCancelRequest = reservationWithPaymentService.deleteReservationAndPayment(reservationId, memberId)
|
|
||||||
val paymentCancelResponse = paymentClient.cancel(paymentCancelRequest)
|
|
||||||
reservationWithPaymentService.updateCanceledTime(
|
|
||||||
paymentCancelRequest.paymentKey,
|
|
||||||
paymentCancelResponse.canceledAt
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResponseEntity.noContent().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/reservations")
|
|
||||||
override fun createReservationWithPayment(
|
|
||||||
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
|
|
||||||
val paymentRequest: PaymentApproveRequest = reservationCreateWithPaymentRequest.toPaymentApproveRequest()
|
|
||||||
val paymentResponse: PaymentApproveResponse = paymentClient.confirm(paymentRequest)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val response: ReservationCreateResponse =
|
|
||||||
reservationWithPaymentService.createReservationAndPayment(
|
|
||||||
reservationCreateWithPaymentRequest,
|
|
||||||
paymentResponse,
|
|
||||||
memberId
|
|
||||||
)
|
|
||||||
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
|
|
||||||
.body(CommonApiResponse(response))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val cancelRequest = PaymentCancelRequest(
|
|
||||||
paymentRequest.paymentKey,
|
|
||||||
paymentRequest.amount,
|
|
||||||
e.message!!
|
|
||||||
)
|
|
||||||
val paymentCancelResponse = paymentClient.cancel(cancelRequest)
|
|
||||||
reservationWithPaymentService.createCanceledPayment(
|
|
||||||
paymentCancelResponse,
|
|
||||||
paymentResponse.approvedAt,
|
|
||||||
paymentRequest.paymentKey
|
|
||||||
)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/reservations/admin")
|
|
||||||
override fun createReservationByAdmin(
|
|
||||||
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
|
|
||||||
val response: ReservationCreateResponse =
|
|
||||||
reservationWriteService.createReservationByAdmin(adminReservationRequest, memberId)
|
|
||||||
|
|
||||||
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
|
|
||||||
.body(CommonApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/reservations/waiting")
|
|
||||||
override fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
|
|
||||||
val response: ReservationRetrieveListResponse = reservationFindService.findAllWaiting()
|
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/reservations/waiting")
|
|
||||||
override fun createWaiting(
|
|
||||||
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
|
|
||||||
val response: ReservationCreateResponse = reservationWriteService.createWaiting(
|
|
||||||
waitingCreateRequest,
|
|
||||||
memberId
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResponseEntity.created(URI.create("/reservations/${response.id}"))
|
|
||||||
.body(CommonApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/reservations/waiting/{id}")
|
|
||||||
override fun cancelWaitingByMember(
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
|
||||||
reservationWriteService.deleteWaiting(reservationId, memberId)
|
|
||||||
|
|
||||||
return ResponseEntity.noContent().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/reservations/waiting/{id}/confirm")
|
|
||||||
override fun confirmWaiting(
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
|
||||||
reservationWriteService.confirmWaiting(reservationId, memberId)
|
|
||||||
|
|
||||||
return ResponseEntity.ok().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/reservations/waiting/{id}/reject")
|
|
||||||
override fun rejectWaiting(
|
|
||||||
@MemberId @Parameter(hidden = true) memberId: Long,
|
|
||||||
@PathVariable("id") reservationId: Long
|
|
||||||
): ResponseEntity<CommonApiResponse<Unit>> {
|
|
||||||
reservationWriteService.deleteWaiting(reservationId, memberId)
|
|
||||||
|
|
||||||
return ResponseEntity.noContent().build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/main/kotlin/roomescape/reservation/web/ReservationDto.kt
Normal file
70
src/main/kotlin/roomescape/reservation/web/ReservationDto.kt
Normal 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
|
||||||
|
)
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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() }
|
|
||||||
)
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
)
|
|
||||||
@ -30,8 +30,7 @@ class ScheduleService(
|
|||||||
fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse {
|
fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse {
|
||||||
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" }
|
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" }
|
||||||
|
|
||||||
return scheduleRepository.findAllByDate(date)
|
return AvailableThemeIdListResponse(scheduleRepository.findAllUniqueThemeIdByDate(date))
|
||||||
.toThemeIdListResponse()
|
|
||||||
.also {
|
.also {
|
||||||
log.info { "[ScheduleService.findThemesByDate] date=${date} 인 ${it.themeIds.size}개 테마 조회 완료" }
|
log.info { "[ScheduleService.findThemesByDate] date=${date} 인 ${it.themeIds.size}개 테마 조회 완료" }
|
||||||
}
|
}
|
||||||
@ -54,8 +53,8 @@ class ScheduleService(
|
|||||||
|
|
||||||
val schedule: ScheduleEntity = findOrThrow(id)
|
val schedule: ScheduleEntity = findOrThrow(id)
|
||||||
|
|
||||||
val createdBy = memberService.findById(schedule.createdBy).name
|
val createdBy = memberService.findSummaryById(schedule.createdBy).name
|
||||||
val updatedBy = memberService.findById(schedule.updatedBy).name
|
val updatedBy = memberService.findSummaryById(schedule.updatedBy).name
|
||||||
|
|
||||||
return schedule.toDetailRetrieveResponse(createdBy, updatedBy)
|
return schedule.toDetailRetrieveResponse(createdBy, updatedBy)
|
||||||
.also {
|
.also {
|
||||||
@ -63,6 +62,16 @@ class ScheduleService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findSummaryById(id: Long): ScheduleSummaryResponse {
|
||||||
|
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" }
|
||||||
|
|
||||||
|
return findOrThrow(id).toSummaryResponse()
|
||||||
|
.also {
|
||||||
|
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse {
|
fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse {
|
||||||
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
|
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
|
||||||
@ -83,6 +92,18 @@ class ScheduleService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun holdSchedule(id: Long) {
|
||||||
|
val schedule: ScheduleEntity = findOrThrow(id)
|
||||||
|
|
||||||
|
if (schedule.status == ScheduleStatus.AVAILABLE) {
|
||||||
|
schedule.hold()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
|
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
|
||||||
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
|
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
|
||||||
|
|||||||
@ -23,7 +23,7 @@ class ScheduleValidator(
|
|||||||
fun validateCanDelete(schedule: ScheduleEntity) {
|
fun validateCanDelete(schedule: ScheduleEntity) {
|
||||||
val status: ScheduleStatus = schedule.status
|
val status: ScheduleStatus = schedule.status
|
||||||
|
|
||||||
if (status !in listOf(ScheduleStatus.AVAILABLE,ScheduleStatus.BLOCKED)) {
|
if (status !in listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)) {
|
||||||
log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
|
log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
|
||||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
|
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,19 @@ interface ScheduleAPI {
|
|||||||
@RequestParam("themeId") themeId: Long
|
@RequestParam("themeId") themeId: Long
|
||||||
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>>
|
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>>
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"])
|
||||||
|
@ApiResponses(
|
||||||
|
ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "일정을 Hold 상태로 변경하여 중복 예약 방지",
|
||||||
|
useReturnTypeSchema = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fun holdSchedule(
|
||||||
|
@PathVariable("id") id: Long
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>>
|
||||||
|
|
||||||
@Admin
|
@Admin
|
||||||
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
|
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))
|
||||||
|
|||||||
@ -12,4 +12,5 @@ enum class ScheduleErrorCode(
|
|||||||
SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."),
|
SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."),
|
||||||
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
|
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
|
||||||
SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."),
|
SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."),
|
||||||
|
SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요.")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
package roomescape.schedule.infrastructure.persistence
|
package roomescape.schedule.infrastructure.persistence
|
||||||
|
|
||||||
import jakarta.persistence.Entity
|
import jakarta.persistence.*
|
||||||
import jakarta.persistence.EnumType
|
|
||||||
import jakarta.persistence.Enumerated
|
|
||||||
import jakarta.persistence.Table
|
|
||||||
import jakarta.persistence.UniqueConstraint
|
|
||||||
import roomescape.common.entity.AuditingBaseEntity
|
import roomescape.common.entity.AuditingBaseEntity
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@ -29,8 +25,12 @@ class ScheduleEntity(
|
|||||||
time?.let { this.time = it }
|
time?.let { this.time = it }
|
||||||
status?.let { this.status = it }
|
status?.let { this.status = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hold() {
|
||||||
|
this.status = ScheduleStatus.HOLD
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ScheduleStatus {
|
enum class ScheduleStatus {
|
||||||
AVAILABLE, PENDING, RESERVED, BLOCKED
|
AVAILABLE, HOLD, RESERVED, BLOCKED
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package roomescape.schedule.infrastructure.persistence
|
package roomescape.schedule.infrastructure.persistence
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
|
||||||
@ -11,4 +12,13 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity>
|
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity>
|
||||||
|
|
||||||
fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT s.themeId
|
||||||
|
FROM ScheduleEntity s
|
||||||
|
WHERE s.date = :date
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findAllUniqueThemeIdByDate(date: LocalDate): List<Long>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,15 @@ class ScheduleController(
|
|||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/schedules/{id}/hold")
|
||||||
|
override fun holdSchedule(
|
||||||
|
@PathVariable("id") id: Long
|
||||||
|
): ResponseEntity<CommonApiResponse<Unit>> {
|
||||||
|
scheduleService.holdSchedule(id)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse())
|
||||||
|
}
|
||||||
|
|
||||||
@PatchMapping("/schedules/{id}")
|
@PatchMapping("/schedules/{id}")
|
||||||
override fun updateSchedule(
|
override fun updateSchedule(
|
||||||
@PathVariable("id") id: Long,
|
@PathVariable("id") id: Long,
|
||||||
|
|||||||
@ -10,8 +10,6 @@ data class AvailableThemeIdListResponse(
|
|||||||
val themeIds: List<Long>
|
val themeIds: List<Long>
|
||||||
)
|
)
|
||||||
|
|
||||||
fun List<ScheduleEntity>.toThemeIdListResponse() = AvailableThemeIdListResponse(this.map { it.themeId })
|
|
||||||
|
|
||||||
data class ScheduleRetrieveResponse(
|
data class ScheduleRetrieveResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val time: LocalTime,
|
val time: LocalTime,
|
||||||
@ -66,3 +64,17 @@ fun ScheduleEntity.toDetailRetrieveResponse(createdBy: String, updatedBy: String
|
|||||||
updatedAt = this.updatedAt,
|
updatedAt = this.updatedAt,
|
||||||
updatedBy = updatedBy
|
updatedBy = updatedBy
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ScheduleSummaryResponse(
|
||||||
|
val date: LocalDate,
|
||||||
|
val time: LocalTime,
|
||||||
|
val themeId: Long,
|
||||||
|
val status: ScheduleStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ScheduleEntity.toSummaryResponse() = ScheduleSummaryResponse(
|
||||||
|
date = this.date,
|
||||||
|
time = this.time,
|
||||||
|
themeId = this.themeId,
|
||||||
|
status = this.status
|
||||||
|
)
|
||||||
|
|||||||
@ -1,67 +1,150 @@
|
|||||||
package roomescape.theme.business
|
package roomescape.theme.business
|
||||||
|
|
||||||
|
import com.github.f4b6a3.tsid.TsidFactory
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import roomescape.theme.implement.ThemeFinder
|
import roomescape.common.config.next
|
||||||
import roomescape.theme.implement.ThemeWriter
|
import roomescape.member.business.MemberService
|
||||||
|
import roomescape.theme.exception.ThemeErrorCode
|
||||||
|
import roomescape.theme.exception.ThemeException
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
|
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import roomescape.theme.web.*
|
import roomescape.theme.web.*
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ThemeService(
|
class ThemeService(
|
||||||
private val themeFinder: ThemeFinder,
|
private val themeRepository: ThemeRepository,
|
||||||
private val themeWriter: ThemeWriter,
|
private val tsidFactory: TsidFactory,
|
||||||
|
private val memberService: MemberService,
|
||||||
|
private val themeValidator: ThemeValidator
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findById(id: Long): ThemeEntity {
|
fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeSummaryListResponse {
|
||||||
log.debug { "[ThemeService.findById] 시작: themeId=$id" }
|
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
|
||||||
|
val result: MutableList<ThemeEntity> = mutableListOf()
|
||||||
|
|
||||||
return themeFinder.findById(id)
|
for (id in request.themeIds) {
|
||||||
.also { log.info { "[ThemeService.findById] 완료: themeId=$id, name=${it.name}" } }
|
val theme: ThemeEntity? = themeRepository.findByIdOrNull(id)
|
||||||
|
if (theme == null) {
|
||||||
|
log.warn { "[ThemeService.findThemesByIds] id=${id} 인 테마 조회 실패" }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.add(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toRetrieveListResponse().also {
|
||||||
|
log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findThemes(): ThemeRetrieveListResponse {
|
fun findThemesForReservation(): ThemeSummaryListResponse {
|
||||||
log.debug { "[ThemeService.findThemes] 시작" }
|
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
|
||||||
|
|
||||||
return themeFinder.findAll()
|
return themeRepository.findOpenedThemes()
|
||||||
.toRetrieveListResponse()
|
.toRetrieveListResponse()
|
||||||
.also { log.info { "[ThemeService.findThemes] 완료. ${it.themes.size}개 반환" } }
|
.also { log.info { "[ThemeService.findThemesForReservation] ${it.themes.size}개 테마 조회 완료" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse {
|
fun findAdminThemes(): AdminThemeSummaryRetrieveListResponse {
|
||||||
log.debug { "[ThemeService.findMostReservedThemes] 시작: count=$count" }
|
log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
|
||||||
|
|
||||||
val today = LocalDate.now()
|
return themeRepository.findAll()
|
||||||
val startFrom = today.minusDays(7)
|
.toAdminThemeSummaryListResponse()
|
||||||
val endAt = today.minusDays(1)
|
.also { log.info { "[ThemeService.findAdminThemes] ${it.themes.size}개 테마 조회 완료" } }
|
||||||
|
}
|
||||||
|
|
||||||
return themeFinder.findMostReservedThemes(count, startFrom, endAt)
|
@Transactional(readOnly = true)
|
||||||
.toRetrieveListResponse()
|
fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse {
|
||||||
.also { log.info { "[ThemeService.findMostReservedThemes] ${it.themes.size}개 반환" } }
|
log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
|
||||||
|
|
||||||
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
|
val createdBy = memberService.findSummaryById(theme.createdBy).name
|
||||||
|
val updatedBy = memberService.findSummaryById(theme.updatedBy).name
|
||||||
|
|
||||||
|
return theme.toAdminThemeDetailResponse(createdBy, updatedBy)
|
||||||
|
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun findSummaryById(id: Long): ThemeSummaryResponse {
|
||||||
|
log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" }
|
||||||
|
|
||||||
|
return findOrThrow(id).toSummaryResponse()
|
||||||
|
.also { log.info { "[ThemeService.findById] 테마 조회 완료: id=$id" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
|
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponseV2 {
|
||||||
log.debug { "[ThemeService.createTheme] 시작: name=${request.name}" }
|
log.info { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" }
|
||||||
|
|
||||||
return themeWriter.create(request.name, request.description, request.thumbnail)
|
themeValidator.validateCanCreate(request)
|
||||||
.toCreateResponse()
|
|
||||||
.also { log.info { "[ThemeService.createTheme] 테마 생성 완료: name=${it.name} themeId=${it.id}" } }
|
val theme: ThemeEntity = themeRepository.save(
|
||||||
|
request.toEntity(tsidFactory.next())
|
||||||
|
)
|
||||||
|
|
||||||
|
return ThemeCreateResponseV2(theme.id).also {
|
||||||
|
log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteTheme(id: Long) {
|
fun deleteTheme(id: Long) {
|
||||||
log.debug { "[ThemeService.deleteTheme] 시작: themeId=$id" }
|
log.info { "[ThemeService.deleteTheme] 테마 삭제 시작: id=${id}" }
|
||||||
|
|
||||||
val theme: ThemeEntity = themeFinder.findById(id)
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
themeWriter.delete(theme)
|
themeRepository.delete(theme).also {
|
||||||
.also { log.info { "[ThemeService.deleteTheme] 완료: themeId=$id, name=${theme.name}" } }
|
log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
|
||||||
|
log.info { "[ThemeService.updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
|
||||||
|
|
||||||
|
if (request.isAllParamsNull()) {
|
||||||
|
log.info { "[ThemeService.updateTheme] 테마 변경 사항 없음: id=${id}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
themeValidator.validateCanUpdate(request)
|
||||||
|
|
||||||
|
val theme: ThemeEntity = findOrThrow(id)
|
||||||
|
|
||||||
|
theme.modifyIfNotNull(
|
||||||
|
request.name,
|
||||||
|
request.description,
|
||||||
|
request.thumbnailUrl,
|
||||||
|
request.difficulty,
|
||||||
|
request.price,
|
||||||
|
request.minParticipants,
|
||||||
|
request.maxParticipants,
|
||||||
|
request.availableMinutes,
|
||||||
|
request.expectedMinutesFrom,
|
||||||
|
request.expectedMinutesTo,
|
||||||
|
request.isOpen,
|
||||||
|
).also {
|
||||||
|
log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findOrThrow(id: Long): ThemeEntity {
|
||||||
|
log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" }
|
||||||
|
|
||||||
|
return themeRepository.findByIdOrNull(id)
|
||||||
|
?.also { log.info { "[ThemeService.findOrThrow] 테마 조회 완료: id=$id" } }
|
||||||
|
?: run {
|
||||||
|
log.warn { "[ThemeService.updateTheme] 테마 조회 실패: id=$id" }
|
||||||
|
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,8 +5,8 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import roomescape.theme.exception.ThemeErrorCode
|
import roomescape.theme.exception.ThemeErrorCode
|
||||||
import roomescape.theme.exception.ThemeException
|
import roomescape.theme.exception.ThemeException
|
||||||
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2
|
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import roomescape.theme.web.ThemeCreateRequestV2
|
import roomescape.theme.web.ThemeCreateRequest
|
||||||
import roomescape.theme.web.ThemeUpdateRequest
|
import roomescape.theme.web.ThemeUpdateRequest
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
@ -16,8 +16,8 @@ const val MIN_PARTICIPANTS = 1
|
|||||||
const val MIN_DURATION = 1
|
const val MIN_DURATION = 1
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class ThemeValidatorV2(
|
class ThemeValidator(
|
||||||
private val themeRepository: ThemeRepositoryV2,
|
private val themeRepository: ThemeRepository,
|
||||||
) {
|
) {
|
||||||
fun validateCanUpdate(request: ThemeUpdateRequest) {
|
fun validateCanUpdate(request: ThemeUpdateRequest) {
|
||||||
validateProperties(
|
validateProperties(
|
||||||
@ -30,7 +30,7 @@ class ThemeValidatorV2(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun validateCanCreate(request: ThemeCreateRequestV2) {
|
fun validateCanCreate(request: ThemeCreateRequest) {
|
||||||
if (themeRepository.existsByName(request.name)) {
|
if (themeRepository.existsByName(request.name)) {
|
||||||
log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" }
|
log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" }
|
||||||
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
|
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
|
||||||
@ -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>>
|
|
||||||
}
|
|
||||||
@ -11,13 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody
|
|||||||
import roomescape.auth.web.support.Admin
|
import roomescape.auth.web.support.Admin
|
||||||
import roomescape.auth.web.support.LoginRequired
|
import roomescape.auth.web.support.LoginRequired
|
||||||
import roomescape.common.dto.response.CommonApiResponse
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
import roomescape.theme.web.AdminThemeDetailRetrieveResponse
|
import roomescape.theme.web.*
|
||||||
import roomescape.theme.web.AdminThemeSummaryRetrieveListResponse
|
|
||||||
import roomescape.theme.web.ThemeCreateRequestV2
|
|
||||||
import roomescape.theme.web.ThemeCreateResponseV2
|
|
||||||
import roomescape.theme.web.ThemeListRetrieveRequest
|
|
||||||
import roomescape.theme.web.ThemeUpdateRequest
|
|
||||||
import roomescape.theme.web.ThemeRetrieveListResponseV2
|
|
||||||
|
|
||||||
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.")
|
@Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.")
|
||||||
interface ThemeAPIV2 {
|
interface ThemeAPIV2 {
|
||||||
@ -35,7 +29,7 @@ interface ThemeAPIV2 {
|
|||||||
@Admin
|
@Admin
|
||||||
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
|
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
fun createTheme(@Valid @RequestBody themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
|
fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
|
||||||
|
|
||||||
@Admin
|
@Admin
|
||||||
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
|
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])
|
||||||
@ -53,10 +47,10 @@ interface ThemeAPIV2 {
|
|||||||
@LoginRequired
|
@LoginRequired
|
||||||
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
|
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
|
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
|
||||||
|
|
||||||
@LoginRequired
|
@LoginRequired
|
||||||
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
|
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
|
||||||
fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
|
fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity<CommonApiResponse<ThemeSummaryListResponse>>
|
||||||
}
|
}
|
||||||
@ -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}개 테마 조회 완료" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user