[#46] 더미 데이터 생성 및 1개의 슬로우쿼리 개선 #47

Merged
pricelees merged 15 commits from feat/#46 into main 2025-09-27 06:38:44 +00:00
31 changed files with 2279 additions and 501 deletions

View File

@ -1,6 +1,12 @@
import apiClient from "@_api/apiClient";
import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes";
import type { AuditInfo } from "@_api/common/commonTypes";
import type {AuditInfo} from "@_api/common/commonTypes";
import type {
AdminScheduleSummaryListResponse,
ScheduleCreateRequest,
ScheduleCreateResponse,
ScheduleUpdateRequest,
ScheduleWithThemeListResponse
} from "./scheduleTypes";
// admin
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {

View File

@ -12,11 +12,11 @@ export const getStores = async (sidoCode?: string, sigunguCode?: string): Promis
const queryParams: string[] = [];
if (sidoCode && sidoCode.trim() !== '') {
queryParams.push(`sidoCode=${sidoCode}`);
queryParams.push(`sido=${sidoCode}`);
}
if (sigunguCode && sigunguCode.trim() !== '') {
queryParams.push(`sigunguCode=${sigunguCode}`);
queryParams.push(`sigungu=${sigunguCode}`);
}
const baseUrl = `/stores`;

View File

@ -42,3 +42,7 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<Th
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
}
export const fetchMostReservedThemes = async (count: number): Promise<ThemeInfoListResponse> => {
return await apiClient.get<ThemeInfoListResponse>(`/themes/most-reserved?count=${count}`);
};

View File

@ -1,8 +1,7 @@
import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
import '@_css/home-page-v2.css';
import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {fetchThemesByIds} from '@_api/theme/themeAPI';
import {fetchMostReservedThemes} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
const HomePage: React.FC = () => {
@ -13,19 +12,8 @@ const HomePage: React.FC = () => {
useEffect(() => {
const fetchData = async () => {
try {
const themeIds = await fetchMostReservedThemeIds().then(res => {
const themeIds = res.themeIds;
if (themeIds.length === 0) {
setRanking([]);
return;
}
return themeIds;
})
if (themeIds === undefined) return;
if (themeIds.length === 0) return;
const response = await fetchThemesByIds({ themeIds: themeIds });
const themeFetchCount = 10;
const response = await fetchMostReservedThemes(themeFetchCount);
setRanking(response.themes.map(mapThemeResponse));
} catch (err) {
console.error('Error fetching ranking:', err);

View File

@ -60,9 +60,13 @@ const ReservationStep1Page: React.FC = () => {
}, [selectedSido]);
useEffect(() => {
getStores(selectedSido, selectedSigungu)
.then(res => setStoreList(res.stores))
.catch(handleError);
if (selectedSido) {
getStores(selectedSido, selectedSigungu)
.then(res => setStoreList(res.stores))
.catch(handleError);
} else {
setStoreList([]);
}
setSelectedStore(null);
}, [selectedSido, selectedSigungu]);

845
query.md Normal file
View File

@ -0,0 +1,845 @@
## Auth
**로그인**
```sql
-- 회원
-- 이메일로 회원 조회
SELECT
u.id
FROM
users u
WHERE
u.email = ?
LIMIT 1;
-- 연락처로 회원 조회
SELECT
u.id
FROM
users u
WHERE
u.phone = ?
LIMIT 1;
-- 회원 추가
INSERT INTO users (
created_at, created_by, email, name, password, phone, region_code,
status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 회원 상태 이력 추가
INSERT INTO user_status_history (
created_at, created_by, reason, status, updated_at, updated_by,
user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
);
```
### Payment
**결제 승인 & 저장**
```sql
-- 결제 정보 추가
INSERT INTO payment ( approved_at, method, order_id, payment_key, requested_at, reservation_id, status, total_amount, type, id
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 결제 상세 정보 추가
INSERT INTO payment_detail ( payment_id, supplied_amount, vat, id
) VALUES ( ?, ?, ?, ?
);
-- 카드 결제 상세 정보 추가
INSERT INTO payment_card_detail ( amount, approval_number, card_number, card_type, easypay_discount_amount, easypay_provider_code, installment_plan_months, is_interest_free, issuer_code, owner_type, id
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**결제 취소**
SQL
```sql
-- 예약 ID로 결제 정보 조회
SELECT
p.id,
p.approved_at,
p.method,
p.order_id,
p.payment_key,
p.requested_at,
p.reservation_id,
p.status,
p.total_amount,
p.type
FROM
payment p
WHERE
p.reservation_id = ?;
-- 추가
-- 취소된 결제 정보 추가
INSERT INTO canceled_payment (
cancel_amount, cancel_reason, canceled_at, canceled_by,
card_discount_amount, easypay_discount_amount, payment_id,
requested_at, transfer_discount_amount, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
### Region
**모든 시/도 조회**
```sql
SELECT DISTINCT
r.sido_code,
r.sido_name
FROM
region r
ORDER BY
r.sido_name;
```
**시/군/구 조회**
```sql
SELECT
r.sigungu_code,
r.sigungu_name
FROM
region r
WHERE
r.sido_code = ?
GROUP BY
r.sigungu_code, r.sigungu_name
ORDER BY
r.sigungu_name;
```
**지역 코드 조회**
```sql
SELECT
r.code
FROM
region r
WHERE
r.sido_code = ? AND r.sigungu_code = ?;
```
### Reservation
**Pending 예약 생성**
```sql
-- schedule 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- theme 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 예약 추가
INSERT INTO reservation (
created_at, created_by, participant_count, requirement,
reserver_contact, reserver_name, schedule_id, status,
updated_at, updated_by, user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**확정**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 예약 확정
UPDATE
reservation
SET
participant_count = ?, requirement = ?, reserver_contact = ?,
reserver_name = ?, schedule_id = ?, status = ?,
updated_at = ?, updated_by = ?, user_id = ?
WHERE
id = ?;
-- Schedule 확정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**취소**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 취소 예약 추가
INSERT INTO canceled_reservation (
cancel_reason, canceled_at, canceled_by,
reservation_id, status, id
) VALUES (
?, ?, ?, ?, ?, ?
);
-- 예약 취소
UPDATE
reservation
SET
participant_count = ?, requirement = ?, reserver_contact = ?,
reserver_name = ?, schedule_id = ?, status = ?,
updated_at = ?, updated_by = ?, user_id = ?
WHERE
id = ?;
-- 일정 활성화
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**회원 예약 조회**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.user_id = ? AND r.status IN (?, ?);
-- 일정 조회 -> 각 예약별 1개씩(N개)
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id
JOIN store st ON st.id = s.store_id
WHERE
s.id = ?;
```
**예약 상세 조회**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 회원 연락처 정보 조회
SELECT
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
FROM
users u
WHERE
u.id = ?;
-- 결제 정보 조회
SELECT
p.id, p.approved_at, p.method, p.order_id, p.payment_key,
p.requested_at, p.reservation_id, p.status, p.total_amount, p.type
FROM
payment p
WHERE
p.reservation_id = ?;
-- 결제 상세 정보 조회
SELECT
pd.id,
CASE
WHEN pbt.id IS NOT NULL THEN 1 -- bank_transfer
WHEN pcd.id IS NOT NULL THEN 2 -- card
WHEN pep.id IS NOT NULL THEN 3 -- easypay
WHEN pd.id IS NOT NULL THEN 0 -- etc
END AS payment_type,
pd.payment_id, pd.supplied_amount, pd.vat,
pbt.bank_code, pbt.settlement_status,
pcd.amount, pcd.approval_number, pcd.card_number, pcd.card_type,
pcd.easypay_discount_amount, pcd.easypay_provider_code,
pcd.installment_plan_months, pcd.is_interest_free, pcd.issuer_code,
pcd.owner_type,
pep.amount AS easypay_amount,
pep.discount_amount AS easypay_discount_amount,
pep.easypay_provider_code AS easypay_provider
FROM
payment_detail pd
LEFT JOIN payment_bank_transfer_detail pbt ON pd.id = pbt.id
LEFT JOIN payment_card_detail pcd ON pd.id = pcd.id
LEFT JOIN payment_easypay_prepaid_detail pep ON pd.id = pep.id
WHERE
pd.payment_id = ?;
-- 취소 결제 정보 조회
SELECT
cp.id, cp.cancel_amount, cp.cancel_reason, cp.canceled_at,
cp.canceled_by, cp.card_discount_amount, cp.easypay_discount_amount,
cp.payment_id, cp.requested_at, cp.transfer_discount_amount
FROM
canceled_payment cp
WHERE
cp.payment_id = ?;
```
### Schedule
**날짜, 시간, 테마로 조회**
```sql
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR t.id = ?)
JOIN store st ON st.id = s.store_id AND st.id = ?
WHERE
s.date = ?
```
**감사 정보 조회**
```sql
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 작업자 조회(createdBy, updatedBy)
SELECT
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
a.updated_by
FROM
admin a
WHERE
a.id = ?;
```
**일정 생성**
```sql
-- 날짜, 시간, 테마가 같은 일정 존재 여부 확인
SELECT EXISTS (
SELECT 1
FROM schedule s
WHERE
s.store_id = ?
AND s.date = ?
AND s.theme_id = ?
AND s.time = ?
);
-- 시간이 겹치는 같은 날의 일정이 있는지 확인
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR s.theme_id = ?)
JOIN store st ON st.id = s.store_id AND st.id = ?
WHERE
s.date = ?
-- 일정 추가
INSERT INTO schedule (
created_at, created_by, date, status, store_id,
theme_id, time, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**일정 수정**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 수정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**일정 삭제**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 삭제
DELETE FROM schedule
WHERE id = ?;
```
**상태 → HOLD 변경**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 수정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
### Store
**매장 상세 조회**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 지역 정보 조회
SELECT
r.code, r.sido_code, r.sido_name, r.sigungu_code, r.sigungu_name
FROM
region r
WHERE
r.code = ?;
-- 감사 정보 조회(createdBy, updatedBy)
SELECT
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
a.updated_by
FROM
admin a
WHERE
a.id = ?;
```
**매장 등록**
```sql
-- 이름 중복 확인
SELECT s.id FROM store s WHERE s.name = ? LIMIT 1;
-- 연락처 중복 확인
SELECT s.id FROM store s WHERE s.contact = ? LIMIT 1;
-- 주소 중복 확인
SELECT s.id FROM store s WHERE s.address = ? LIMIT 1;
-- 사업자번호 중복 확인
SELECT s.id FROM store s WHERE s.business_reg_num = ? LIMIT 1;
-- 추가
INSERT INTO store (
address, business_reg_num, contact, created_at, created_by,
name, region_code, status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**매장 수정**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 수정
UPDATE
store
SET
address = ?, business_reg_num = ?, contact = ?, name = ?,
region_code = ?, status = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**비활성화(status = DISABLE)**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 수정
UPDATE
store
SET
address = ?, business_reg_num = ?, contact = ?, name = ?,
region_code = ?, status = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**모든 매장 조회**
```sql
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.status = 'ACTIVE'
AND (? IS NULL OR s.region_code LIKE ?);
```
**개별 매장 상세 조회**
```sql
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
```
### Theme
**생성**
```sql
-- 이름으로 조회
SELECT
t.id
FROM
theme t
WHERE
t.name = ?
LIMIT 1;
-- 추가
INSERT INTO theme (
available_minutes, created_at, created_by, description, difficulty,
expected_minutes_from, expected_minutes_to, is_active, max_participants,
min_participants, name, price, thumbnail_url, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**Active인 모든 테마 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.is_active = TRUE;
```
**테마 목록 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t;
```
**감사 정보 포함 개별 테마 상세 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
```
**개별 테마 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
```
**삭제**
```sql
-- 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 삭제
DELETE FROM theme WHERE id = ?;
```
**수정**
```sql
-- 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 수정
UPDATE
theme
SET
available_minutes = ?, description = ?, difficulty = ?,
expected_minutes_from = ?, expected_minutes_to = ?, is_active = ?,
max_participants = ?, min_participants = ?, name = ?, price = ?,
thumbnail_url = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**인기 테마 조회**
```sql
SELECT
t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price,
t.min_participants, t.max_participants,
t.available_minutes, t.expected_minutes_from, t.expected_minutes_to
FROM
theme t
JOIN (
SELECT
s.theme_id, count(*) as reservation_count
FROM
schedule s
JOIN
reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED'
WHERE
s.status = 'RESERVED'
AND (s.date BETWEEN :startFrom AND :endAt)
GROUP BY
s.theme_id
ORDER BY
reservation_count desc
LIMIT :count
) ranked_themes ON t.id = ranked_themes.theme_id
```
### User
**회원가입**
```sql
-- 이메일 중복 확인
SELECT
u.id
FROM
users u
WHERE
u.email = ?
LIMIT 1;
-- 연락처 중복 확인
SELECT
u.id
FROM
users u
WHERE
u.phone = ?
LIMIT 1;
-- 추가
INSERT INTO users (
created_at, created_by, email, name, password, phone, region_code,
status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 상태 변경 이력 추가
INSERT INTO user_status_history (
created_at, created_by, reason, status, updated_at, updated_by,
user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
);
```
**연락처 정보 조회**
```sql
SELECT
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
FROM
users u
WHERE
u.id = ?;
```

View File

@ -121,23 +121,6 @@ class ReservationService(
}
}
@Transactional(readOnly = true)
fun findMostReservedThemeIds(count: Int): MostReservedThemeIdListResponse {
log.info { "[ReservationService.findMostReservedThemeIds] 인기 테마 조회 시작: count=$count" }
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now())
val previousWeekSaturday = previousWeekSunday.plusDays(6)
val themeIds: List<Long> = reservationRepository.findMostReservedThemeIds(
dateFrom = previousWeekSunday,
dateTo = previousWeekSaturday,
count = count
)
return MostReservedThemeIdListResponse(themeIds = themeIds).also {
log.info { "[ReservationService.findMostReservedThemeIds] 인기 테마 조회 완료: count=${it.themeIds.size}" }
}
}
private fun findOrThrow(id: Long): ReservationEntity {
log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" }

View File

@ -16,14 +16,6 @@ import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.*
interface ReservationAPI {
@Public
@Operation(summary = "가장 많이 예약된 테마 ID 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findMostReservedThemeIds(
@RequestParam count: Int
): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>>
@Operation(summary = "결제 전 임시 예약 저장")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun createPendingReservation(

View File

@ -1,27 +1,8 @@
package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDate
interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
@Query("""
SELECT s.themeId
FROM ReservationEntity r
JOIN ScheduleEntity s ON s._id = r.scheduleId
WHERE r.status = roomescape.reservation.infrastructure.persistence.ReservationStatus.CONFIRMED
AND s.date BETWEEN :dateFrom AND :dateTo
GROUP BY s.themeId
ORDER BY count(r) DESC
LIMIT :count
""")
fun findMostReservedThemeIds(
@Param("dateFrom") dateFrom: LocalDate,
@Param("dateTo") dateTo: LocalDate,
@Param("count") count: Int
): List<Long>
}

View File

@ -14,16 +14,6 @@ import roomescape.reservation.docs.ReservationAPI
class ReservationController(
private val reservationService: ReservationService
) : ReservationAPI {
@GetMapping("/popular-themes")
override fun findMostReservedThemeIds(
@RequestParam count: Int
): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>> {
val response = reservationService.findMostReservedThemeIds(count)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/pending")
override fun createPendingReservation(
@User user: CurrentUserContext,

View File

@ -11,11 +11,11 @@ class ScheduleOverview(
val storeName: String,
val date: LocalDate,
val time: LocalTime,
val status: ScheduleStatus,
val themeId: Long,
val themeName: String,
val themeDifficulty: Difficulty,
val themeAvailableMinutes: Short,
val status: ScheduleStatus
val themeAvailableMinutes: Short
) {
fun getEndAt(): LocalTime {
return time.plusMinutes(themeAvailableMinutes.toLong())

View File

@ -8,7 +8,8 @@ import java.time.LocalTime
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
@Query("""
@Query(
"""
SELECT
COUNT(s) > 0
FROM
@ -18,35 +19,34 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
AND s.date = :date
AND s.themeId = :themeId
AND s.time = :time
""")
"""
)
fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean
@Query(
"""
SELECT
SELECT
new roomescape.schedule.business.domain.ScheduleOverview(
s._id,
st._id,
st.name,
s.date,
s.time,
s.status,
t._id,
t.name,
t.difficulty,
t.availableMinutes,
s.status
t.availableMinutes
)
FROM
ScheduleEntity s
JOIN
ThemeEntity t ON t._id = s.themeId
ThemeEntity t ON t._id = s.themeId and (:themeId IS NULL OR t._id = :themeId)
JOIN
StoreEntity st ON st._id = s.storeId
StoreEntity st ON st._id = s.storeId and st._id = :storeId
WHERE
s.storeId = :storeId
AND s.date = :date
AND (:themeId IS NULL OR s.themeId = :themeId)
"""
s.date = :date
"""
)
fun findStoreSchedulesWithThemeByDate(
storeId: Long,
@ -54,21 +54,22 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
themeId: Long? = null
): List<ScheduleOverview>
@Query("""
SELECT
@Query(
"""
SELECT
new roomescape.schedule.business.domain.ScheduleOverview(
s._id,
st._id,
st.name,
s.date,
s.time,
t._id,
t.name,
t.difficulty,
t.availableMinutes,
s.status
)
FROM
s._id,
st._id,
st.name,
s.date,
s.time,
s.status,
t._id,
t.name,
t.difficulty,
t.availableMinutes
)
FROM
ScheduleEntity s
JOIN
ThemeEntity t ON t._id = s.themeId
@ -76,6 +77,7 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
StoreEntity st ON st._id = s.storeId
WHERE
s._id = :id
""")
"""
)
fun findOverviewByIdOrNull(id: Long): ScheduleOverview?
}

View File

@ -9,11 +9,13 @@ import org.springframework.transaction.annotation.Transactional
import roomescape.admin.business.AdminService
import roomescape.common.config.next
import roomescape.common.dto.AuditInfo
import roomescape.common.util.DateUtils
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.*
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@ -43,13 +45,18 @@ class ThemeService(
}
@Transactional(readOnly = true)
fun findAllInfosByIds(request: ThemeIdListRequest): ThemeInfoListResponse {
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
val result: List<ThemeEntity> = themeRepository.findAllByIdIn(request.themeIds)
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
log.info { "[ThemeService.findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now())
val previousWeekSaturday = previousWeekSunday.plusDays(6)
return themeRepository.findMostReservedThemeByDateAndCount(previousWeekSunday, previousWeekSaturday, count)
.toListResponse()
.also {
log.info { "[ThemeService.findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" }
}
return result.toInfoListResponse().also {
log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" }
}
}
// ========================================

View File

@ -0,0 +1,15 @@
package roomescape.theme.business.domain
class ThemeInfo(
val id: Long,
val name: String,
val description: String,
val difficulty: String,
val thumbnailUrl: String,
val price: Int,
val minParticipants: Short,
val maxParticipants: Short,
val availableMinutes: Short,
val expectedMinutesFrom: Short,
val expectedMinutesTo: Short
)

View File

@ -5,8 +5,10 @@ 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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.web.support.AdminOnly
@ -50,13 +52,13 @@ interface AdminThemeAPI {
}
interface PublicThemeAPI {
@Public
@Operation(summary = "입력된 모든 ID에 대한 테마 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findThemeInfosByIds(request: ThemeIdListRequest): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
@Public
@Operation(summary = "입력된 테마 ID에 대한 정보 조회")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun findThemeInfoById(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeInfoResponse>>
@Public
@Operation(summary = "지난 주에 가장 많이 예약된 count 개의 테마 조회")
@GetMapping("/most-reserved")
fun findMostReservedThemeLastWeek(@RequestParam count: Int): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
}

View File

@ -2,6 +2,8 @@ package roomescape.theme.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import roomescape.theme.business.domain.ThemeInfo
import java.time.LocalDate
interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
@ -10,5 +12,32 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
fun existsByName(name: String): Boolean
fun findAllByIdIn(themeIds: List<Long>): List<ThemeEntity>
@Query(
value = """
SELECT
t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price,
t.min_participants, t.max_participants,
t.available_minutes, t.expected_minutes_from, t.expected_minutes_to
FROM
theme t
JOIN (
SELECT
s.theme_id, count(*) as reservation_count
FROM
schedule s
JOIN
reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED'
WHERE
s.status = 'RESERVED'
AND (s.date BETWEEN :startFrom AND :endAt)
GROUP BY
s.theme_id
ORDER BY
reservation_count desc
LIMIT :count
) ranked_themes ON t.id = ranked_themes.theme_id
""",
nativeQuery = true
)
fun findMostReservedThemeByDateAndCount(startFrom: LocalDate, endAt: LocalDate, count: Int): List<ThemeInfo>
}

View File

@ -1,6 +1,5 @@
package roomescape.theme.web
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import roomescape.common.dto.response.CommonApiResponse
@ -12,15 +11,6 @@ import roomescape.theme.docs.PublicThemeAPI
class ThemeController(
private val themeService: ThemeService,
) : PublicThemeAPI {
@PostMapping("/batch")
override fun findThemeInfosByIds(
@Valid @RequestBody request: ThemeIdListRequest
): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> {
val response = themeService.findAllInfosByIds(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/{id}")
override fun findThemeInfoById(
@PathVariable id: Long
@ -29,4 +19,13 @@ class ThemeController(
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/most-reserved")
override fun findMostReservedThemeLastWeek(
@RequestParam count: Int
): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> {
val response = themeService.findMostReservedThemeLastWeek(count)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -1,6 +1,6 @@
package roomescape.theme.web
import roomescape.theme.infrastructure.persistence.Difficulty
import roomescape.theme.business.domain.ThemeInfo
import roomescape.theme.infrastructure.persistence.ThemeEntity
data class ThemeIdListRequest(
@ -12,7 +12,7 @@ data class ThemeInfoResponse(
val name: String,
val thumbnailUrl: String,
val description: String,
val difficulty: Difficulty,
val difficulty: String,
val price: Int,
val minParticipants: Short,
val maxParticipants: Short,
@ -21,7 +21,7 @@ data class ThemeInfoResponse(
val expectedMinutesTo: Short
)
fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
fun ThemeInfo.toInfoResponse() = ThemeInfoResponse(
id = this.id,
name = this.name,
thumbnailUrl = this.thumbnailUrl,
@ -35,10 +35,24 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
expectedMinutesTo = this.expectedMinutesTo
)
fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
id = this.id,
name = this.name,
thumbnailUrl = this.thumbnailUrl,
description = this.description,
difficulty = this.difficulty.name,
price = this.price,
minParticipants = this.minParticipants,
maxParticipants = this.maxParticipants,
availableMinutes = this.availableMinutes,
expectedMinutesFrom = this.expectedMinutesFrom,
expectedMinutesTo = this.expectedMinutesTo
)
data class ThemeInfoListResponse(
val themes: List<ThemeInfoResponse>
)
fun List<ThemeEntity>.toInfoListResponse() = ThemeInfoListResponse(
fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
themes = this.map { it.toInfoResponse() }
)

View File

@ -187,7 +187,7 @@ create table if not exists payment_detail(
create table if not exists payment_bank_transfer_detail (
id bigint primary key,
bank_code varchar(10) not null,
bank_code varchar(20) not null,
settlement_status varchar(20) not null,
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
@ -195,7 +195,7 @@ create table if not exists payment_bank_transfer_detail (
create table if not exists payment_card_detail (
id bigint primary key,
issuer_code varchar(10) not null,
issuer_code varchar(20) not null,
card_type varchar(10) not null,
owner_type varchar(10) not null,
amount integer not null,

View File

@ -187,7 +187,7 @@ create table if not exists payment_detail(
create table if not exists payment_bank_transfer_detail (
id bigint primary key,
bank_code varchar(10) not null,
bank_code varchar(20) not null,
settlement_status varchar(20) not null,
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
@ -195,7 +195,7 @@ create table if not exists payment_bank_transfer_detail (
create table if not exists payment_card_detail (
id bigint primary key,
issuer_code varchar(10) not null,
issuer_code varchar(20) not null,
card_type varchar(10) not null,
owner_type varchar(10) not null,
amount integer not null,

View File

@ -1,99 +0,0 @@
package roomescape.data
import io.kotest.core.spec.style.StringSpec
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.File
const val BASE_DIR = "data"
const val PARSED_REGION_POPULATION_FILE = "$BASE_DIR/region_population.txt"
const val REGION_SQL_FILE = "data/region.sql"
const val MIN_POPULATION_FOR_PER_STORE = 200_000
/**
* 행안부 202508 인구 동향 데이터 사용( /data/population.xlsx )
*/
class PopulationDataSqlParser() : StringSpec({
val regionCodePattern = Regex("^[0-9]{10}$")
"인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다.".config(
enabled = false
) {
val populationXlsx = XSSFWorkbook(File("data/population.xlsx"))
val sheet = populationXlsx.getSheetAt(0)
val allRegion = mutableListOf<List<String>>()
val regionsMoreThanMinPopulation = mutableListOf<List<String?>>()
sheet.rowIterator().forEach { row ->
val regionCode = row.getCell(0)?.stringCellValue ?: return@forEach
if (regionCodePattern.matches(regionCode).not()) {
return@forEach
}
val sidoCode = regionCode.substring(0, 2)
val sigunguCode = regionCode.substring(2, 5)
val population = row.getCell(2).stringCellValue.replace(",", "")
if (Regex("^[0-9]+$").matches(population).not()) {
return@forEach
}
val regionName = row.getCell(1).stringCellValue
if (!regionName.trim().contains(" ")) {
return@forEach
}
val parts = regionName.split(" ")
if (parts.size < 2) {
return@forEach
}
val sidoName = parts[0].trim()
val sigunguName = parts[1].trim()
val populationInt = population.toInt()
if (populationInt <= 0) {
return@forEach
}
if (populationInt >= MIN_POPULATION_FOR_PER_STORE) {
regionsMoreThanMinPopulation.add(
listOf(
regionCode,
sidoCode,
sigunguCode,
sidoName,
sigunguName,
population
)
)
}
allRegion.add(listOf(regionCode, sidoCode, sigunguCode, sidoName, sigunguName))
}
regionsMoreThanMinPopulation.filter {
val sidoName = it[3]
val sigunguName = it[4]
val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName }
!((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2])
}.mapIndexed { idx, values ->
"${values[0]}, ${values[1]}, ${values[2]}, ${values[3]}, ${values[4]}, ${values[5]}"
}.joinToString(separator = "\n").also {
File(PARSED_REGION_POPULATION_FILE).writeText(it)
}
allRegion.distinctBy { it[1] to it[2] }.filter {
val sidoName = it[3]
val sigunguName = it[4]
val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName }
!((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2])
}.joinToString(
prefix = "INSERT INTO region(code, sido_code, sigungu_code, sido_name, sigungu_name) VALUES ",
separator = ",\n"
) { region ->
"('${region[0]}', '${region[1]}', '${region[2]}', '${region[3]}', '${region[4]}')"
}.also {
File(REGION_SQL_FILE).writeText("${it};")
}
}
})

View File

@ -0,0 +1,911 @@
package roomescape.data
import com.github.f4b6a3.tsid.TsidFactory
import io.kotest.core.test.TestCaseOrder
import jakarta.persistence.EntityManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.ActiveProfiles
import roomescape.admin.infrastructure.persistence.AdminEntity
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.common.config.next
import roomescape.common.util.TransactionExecutionUtil
import roomescape.payment.infrastructure.common.*
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.supports.AdminFixture
import roomescape.supports.FunSpecSpringbootTest
import roomescape.supports.randomPhoneNumber
import roomescape.supports.randomString
import roomescape.theme.infrastructure.persistence.Difficulty
import roomescape.user.business.SIGNUP
import roomescape.user.infrastructure.persistence.UserEntity
import roomescape.user.infrastructure.persistence.UserStatus
import roomescape.user.web.UserContactResponse
import java.sql.Timestamp
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
@ActiveProfiles("test", "test-mysql")
abstract class AbstractDataInitializer(
val semaphore: Semaphore = Semaphore(permits = 10),
) : FunSpecSpringbootTest(
enableCleanerExtension = false
) {
@Autowired
lateinit var entityManager: EntityManager
@Autowired
lateinit var jdbcTemplate: JdbcTemplate
@Autowired
lateinit var transactionExecutionUtil: TransactionExecutionUtil
@Autowired
lateinit var tsidFactory: TsidFactory
override fun testCaseOrder(): TestCaseOrder? = TestCaseOrder.Sequential
suspend fun initialize() {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
jdbcTemplate.query("SHOW TABLES") { rs, _ ->
rs.getString(1).lowercase()
}.forEach {
jdbcTemplate.execute("TRUNCATE TABLE $it")
}
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1")
this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { sql ->
jdbcTemplate.execute(sql)
}
}
}
suspend fun executeBatch(sql: String, batchArgs: List<Array<Any>>) {
semaphore.acquire()
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
jdbcTemplate.batchUpdate(sql, batchArgs)
}
semaphore.release()
}
}
class DefaultDataInitializer : AbstractDataInitializer() {
// 1. HQ Admin 추가
// 2. Store 추가 -> CreatedBy / UpdatedBy는 HQ Admin 중 한명으로 고정
// 3. Store Admin 추가 -> CreatedBy / UpdatedBy는 HQ Admin 본인으로 고정
init {
lateinit var superHQAdmin: AdminEntity
// 모든 테이블 초기화 + 지역 데이터 삽입 + HQ 관리자 1명 생성
beforeSpec {
initialize()
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
superHQAdmin = testAuthUtil.createAdmin(AdminFixture.hqDefault.apply {
this.createdBy = this.id
this.updatedBy = this.id
})
}
}
context("관리자, 매장, 테마 초기 데이터 생성") {
test("각 PermissionLevel 마다 20명의 HQ 관리자 생성") {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
AdminPermissionLevel.entries.forEach {
repeat(20) { index ->
AdminFixture.create(
account = "hq_${it.name.lowercase()}_$index",
name = randomKoreanName(),
phone = randomPhoneNumber(),
type = AdminType.HQ,
permissionLevel = it
).apply {
this.createdBy = superHQAdmin.id
this.updatedBy = superHQAdmin.id
}.also {
entityManager.persist(it)
}
}
}
}
}
test("전체 매장 생성") {
val storeDataInitializer = StoreDataInitializer()
val creatableAdminIds: List<String> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)",
Long::class.java
).setParameter("type", AdminType.HQ)
.setParameter(
"permissionLevels",
listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE)
)
.resultList
}.map { it.toString() }
val sqlFile = storeDataInitializer.createStoreDataSqlFile(creatableAdminIds)
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
jdbcTemplate.execute(sqlFile.readText())
}
}
test("각 매장당 1명의 ${AdminPermissionLevel.FULL_ACCESS} 권한의 StoreManager 1명 + ${AdminPermissionLevel.WRITABLE}의 2명 + 나머지 권한은 3명씩 생성") {
val storeAdminCountsByPermissionLevel = mapOf(
AdminPermissionLevel.WRITABLE to 2,
AdminPermissionLevel.READ_ALL to 3,
AdminPermissionLevel.READ_SUMMARY to 3
)
val storeIds: List<Long> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT s.id FROM StoreEntity s",
Long::class.java
).resultList
}.map { it as Long }
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
storeIds.forEach { storeId ->
// StoreManager 1명 생성
val storeManager = AdminFixture.create(
account = "$storeId",
name = randomKoreanName(),
phone = randomPhoneNumber(),
type = AdminType.STORE,
storeId = storeId,
permissionLevel = AdminPermissionLevel.FULL_ACCESS
).apply {
this.createdBy = superHQAdmin.id
this.updatedBy = superHQAdmin.id
}.also {
entityManager.persist(it)
}
storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) ->
repeat(count) { index ->
AdminFixture.create(
account = randomString(),
name = randomKoreanName(),
phone = randomPhoneNumber(),
type = AdminType.STORE,
storeId = storeId,
permissionLevel = permissionLevel
).apply {
this.createdBy = storeManager.id
this.updatedBy = storeManager.id
}.also {
entityManager.persist(it)
}
}
}
entityManager.flush()
entityManager.clear()
}
}
}
test("총 500개의 테마 생성: 지난 2년 전 부터 지금까지의 랜덤 테마 + active 상태인 1달 이내 생성 테마 10개") {
val creatableAdminIds: List<Long> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)",
Long::class.java
).setParameter("type", AdminType.HQ)
.setParameter(
"permissionLevels",
listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE)
)
.resultList
}
val sql =
"INSERT INTO theme (id, name, description, thumbnail_url, is_active, available_minutes, expected_minutes_from, expected_minutes_to, price, difficulty, min_participants, max_participants, created_at, created_by, updated_at, updated_by) VALUES (?," +
"?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
val batchSize = 100
val batchArgs = mutableListOf<Array<Any>>()
repeat(500) { i ->
val randomDay = if (i <= 9) (1..30).random() else (1..365 * 2).random()
val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong())
val randomThemeName =
(1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } }
val availableMinutes = (6..20).random() * 10
val expectedMinutesTo = availableMinutes - ((1..3).random() * 10)
val expectedMinutesFrom = expectedMinutesTo - ((1..2).random() * 10)
val randomPrice = (0..40).random() * 500
val minParticipant = (1..10).random()
val maxParticipant = minParticipant + (1..10).random()
val createdBy = creatableAdminIds.random()
batchArgs.add(
arrayOf(
tsidFactory.next(),
randomThemeName,
"$randomThemeName 설명이에요!!",
"https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRFiuCdwdz88l6pdfsRy1nFl0IHUVI7JMTQHg&s",
if (randomDay <= 30) true else false,
availableMinutes.toShort(),
expectedMinutesFrom.toShort(),
expectedMinutesTo.toShort(),
randomPrice,
Difficulty.entries.random().name,
minParticipant.toShort(),
maxParticipant.toShort(),
Timestamp.valueOf(randomCreatedAt),
createdBy,
Timestamp.valueOf(randomCreatedAt),
createdBy
)
)
}
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
jdbcTemplate.batchUpdate(sql, batchArgs)
}
}
}
}
}
class UserDataInitializer : AbstractDataInitializer() {
val userCount = 1_000_000
init {
context("유저 초기 데이터 생성") {
test("$userCount 명의 회원 생성") {
val regions: List<String> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT r.code FROM RegionEntity r",
String::class.java
).resultList
}
val chunkSize = 10_000
val chunks = userCount / chunkSize
coroutineScope {
(1..chunks).map {
launch(Dispatchers.IO) {
processUsers(chunkSize, regions)
}
}.joinAll()
}
}
test("휴대폰 번호가 중복된 유저들에게 재배정") {
val duplicatePhoneUsers: List<UserEntity> =
transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"""
SELECT u FROM UserEntity u
WHERE u.phone IN (
SELECT u2.phone FROM UserEntity u2
GROUP BY u2.phone
HAVING COUNT(u2.id) > 1
)
ORDER BY u.phone, u.id
""".trimIndent(),
UserEntity::class.java
).resultList
}
jdbcTemplate.execute("CREATE INDEX idx_users__phone ON users (phone)")
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
var currentPhone: String? = null
duplicatePhoneUsers.forEach { user ->
if (user.phone != currentPhone) {
currentPhone = user.phone
} else {
var newPhone: String
do {
newPhone = randomPhoneNumber()
val count: Long = entityManager.createQuery(
"SELECT COUNT(u.id) FROM UserEntity u WHERE u.phone = :phone",
Long::class.java
).setParameter("phone", newPhone)
.singleResult
if (count == 0L) break
} while (true)
user.phone = newPhone
user.updatedAt = LocalDateTime.now()
entityManager.merge(user)
}
}
}
jdbcTemplate.execute("DROP INDEX idx_users__phone ON users")
}
test("회원 상태 변경 이력 저장") {
val userId: List<Long> = transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT u.id FROM UserEntity u",
Long::class.java
).resultList
}
coroutineScope {
userId.chunked(10_000).map { chunk ->
launch(Dispatchers.IO) {
processStatus(chunk)
}
}.joinAll()
}
}
}
}
private suspend fun processStatus(userIds: List<Long>) {
val sql = """
INSERT INTO user_status_history (
id, user_id, reason, status,
created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val batchArgs = mutableListOf<Array<Any>>()
val now = LocalDateTime.now()
userIds.forEach { userId ->
batchArgs.add(
arrayOf(
tsidFactory.next(),
userId,
SIGNUP,
UserStatus.ACTIVE.name,
userId,
userId,
Timestamp.valueOf(now),
Timestamp.valueOf(now)
)
)
}
executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
private suspend fun processUsers(chunkSize: Int, regions: List<String>) {
val sql = """
INSERT INTO users (
id, name, email, password, phone, region_code, status,
created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val batchArgs = mutableListOf<Array<Any>>()
repeat(chunkSize) {
val id: Long = tsidFactory.next()
batchArgs.add(
arrayOf(
id,
randomKoreanName(),
"${randomString()}@sangdol.com",
randomString(),
randomPhoneNumber(),
regions.random(),
UserStatus.ACTIVE.name,
id,
id,
Timestamp.valueOf(LocalDateTime.now()),
Timestamp.valueOf(LocalDateTime.now())
)
)
if (batchArgs.size >= 1_000) {
executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
}
if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
}
class ScheduleDataInitializer : AbstractDataInitializer() {
init {
context("일정 초기 데이터 생성") {
test("테마 생성일 기준으로 다음 3일차, 매일 5개의 일정을 모든 매장에 생성") {
val stores: List<Pair<Long, Long>> = getStoreWithManagers()
val themes: List<Triple<Long, Short, LocalDateTime>> = getThemes()
val maxAvailableMinutes = themes.maxOf { it.second.toInt() }
val scheduleCountPerDay = 5
val startTime = LocalTime.of(10, 0)
var lastTime = startTime
val times = mutableListOf<LocalTime>()
repeat(scheduleCountPerDay) {
times.add(lastTime)
lastTime = lastTime.plusMinutes(maxAvailableMinutes.toLong() + 10L)
}
coroutineScope {
themes.forEach { theme ->
launch(Dispatchers.IO) {
processTheme(theme, stores, times)
}
}
}
}
}
}
private suspend fun processTheme(
theme: Triple<Long, Short, LocalDateTime>,
stores: List<Pair<Long, Long>>,
times: List<LocalTime>
) {
val sql = """
INSERT INTO schedule (
id, store_id, theme_id, date, time, status,
created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val batchArgs = mutableListOf<Array<Any>>()
val now = LocalDateTime.now()
stores.forEach { (storeId, adminId) ->
(1..3).forEach { dayOffset ->
val date = theme.third.toLocalDate().plusDays(dayOffset.toLong())
times.forEach { time ->
val scheduledAt = LocalDateTime.of(date, time)
val status =
if (scheduledAt.isAfter(now)) ScheduleStatus.AVAILABLE.name else ScheduleStatus.RESERVED.name
batchArgs.add(
arrayOf(
tsidFactory.next(), storeId, theme.first, date, time,
status, adminId, adminId, Timestamp.valueOf(now), Timestamp.valueOf(now)
)
)
if (batchArgs.size >= 500) {
executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
}
}
}
if (batchArgs.isNotEmpty()) {
executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
}
private fun getStoreWithManagers(): List<Pair<Long, Long>> {
return transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT a.storeId, a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel = :permissionLevel",
List::class.java
).setParameter("type", AdminType.STORE)
.setParameter("permissionLevel", AdminPermissionLevel.FULL_ACCESS)
.resultList
}.map {
val array = it as List<*>
Pair(array[0] as Long, array[1] as Long)
}
}
private fun getThemes(): List<Triple<Long, Short, LocalDateTime>> {
return transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
entityManager.createQuery(
"SELECT t._id, t.availableMinutes, t.createdAt FROM ThemeEntity t",
List::class.java
)
.resultList
}.map {
val array = it as List<*>
Triple(array[0] as Long, array[1] as Short, array[2] as LocalDateTime)
}
}
}
/**
* 아래의 ReservationDataInitializer 에서 사용할 임시 DTO 클래스
*/
data class ScheduleWithThemeParticipants(
val scheduleId: Long,
val themeMinParticipants: Short,
val themeMaxParticipants: Short,
)
class ReservationDataInitializer : AbstractDataInitializer() {
init {
context("예약 초기 데이터 생성") {
test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") {
val chunkSize = 10_000
val chunkedSchedules: List<List<ScheduleWithThemeParticipants>> = entityManager.createQuery(
"SELECT new roomescape.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status",
ScheduleWithThemeParticipants::class.java
).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize)
val chunkedUsers: List<List<UserContactResponse>> = entityManager.createQuery(
"SELECT new roomescape.user.web.UserContactResponse(u._id, u.name, u.phone) FROM UserEntity u",
UserContactResponse::class.java
).resultList.chunked(chunkSize)
coroutineScope {
chunkedSchedules.forEachIndexed { idx, schedules ->
launch(Dispatchers.IO) {
processReservation(chunkedUsers[idx % chunkedUsers.size], schedules)
}
}
}
}
}
}
private suspend fun processReservation(
users: List<UserContactResponse>,
schedules: List<ScheduleWithThemeParticipants>
) {
val sql = """
INSERT INTO reservation (
id, user_id, schedule_id,
reserver_name, reserver_contact, participant_count, requirement,
status, created_at, created_by, updated_at, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val batchArgs = mutableListOf<Array<Any>>()
val createdAt = LocalDateTime.now()
schedules.forEachIndexed { idx, schedule ->
val user: UserContactResponse = users[idx % users.size]
batchArgs.add(
arrayOf(
tsidFactory.next(),
user.id,
schedule.scheduleId,
user.name,
user.phone,
(schedule.themeMinParticipants..schedule.themeMaxParticipants).random(),
randomKoreanWords(length = (20..100).random()),
ReservationStatus.CONFIRMED.name,
Timestamp.valueOf(createdAt),
user.id,
Timestamp.valueOf(createdAt),
user.id,
)
)
if (batchArgs.size >= 1_000) {
executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
}
if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() }
}
}
class ReservationWithPrice(
themePrice: Int,
participantCount: Short,
val reservationId: Long,
val totalPrice: Int = (themePrice * participantCount),
)
data class PaymentWithMethods(
val id: Long,
val totalPrice: Int,
val method: PaymentMethod
)
class PaymentDataInitializer : AbstractDataInitializer() {
companion object {
val requestedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().toLocalDateTime())
val approvedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().plusSeconds(5).toLocalDateTime())
val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD)
val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK)
val settlementStatus = "COMPLETED"
val paymentSql: String = """
INSERT INTO payment(
id, reservation_id, type, method,
payment_key, order_id, total_amount, status,
requested_at, approved_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val paymentDetailSql: String = """
INSERT INTO payment_detail(
id, payment_id, supplied_amount, vat
) VALUES (?, ?, ?, ?)
""".trimIndent()
val paymentCardDetailSql: String = """
INSERT INTO payment_card_detail(
id, issuer_code, card_type, owner_type,
amount, card_number, approval_number, installment_plan_months,
is_interest_free, easypay_provider_code, easypay_discount_amount
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val paymentEasypayPrepaidDetailSql: String = """
INSERT INTO payment_easypay_prepaid_detail(
id, easypay_provider_code, amount, discount_amount
) VALUES (?, ?, ?, ?)
""".trimIndent()
val paymentBankTransferDetailSql: String = """
INSERT INTO payment_bank_transfer_detail(
id, bank_code, settlement_status
) VALUES (?, ?, ?)
""".trimIndent()
}
init {
context("결제 데이터 초기화") {
test("모든 예약에 맞춰 1:1로 결제 및 결제 상세 데이터를 생성한다.") {
val allReservations: List<ReservationWithPrice> = entityManager.createQuery(
"SELECT t.price, r.participantCount, r._id FROM ReservationEntity r JOIN ScheduleEntity s ON s._id = r.scheduleId JOIN ThemeEntity t ON t.id = s.themeId",
List::class.java
).resultList.map {
val items = it as List<*>
ReservationWithPrice(
themePrice = items[0] as Int,
participantCount = items[1] as Short,
reservationId = items[2] as Long
)
}
coroutineScope {
allReservations.chunked(10_000).forEach { reservations ->
launch(Dispatchers.IO) {
processPaymentAndDefaultDetail(reservations)
}
}
}
}
test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") {
val allPayments: List<PaymentWithMethods> = entityManager.createQuery(
"SELECT new roomescape.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId",
PaymentWithMethods::class.java
).resultList
coroutineScope {
allPayments.chunked(10_000).forEach { payments ->
launch(Dispatchers.IO) {
processPaymentDetail(payments)
}
}
}
}
test("null 컴파일 에러를 피하기 위해 문자열 null로 임시 지정한 컬럼을 변경한다.") {
jdbcTemplate.execute("CREATE INDEX idx_payment_card_detail_easypay ON payment_card_detail (easypay_provider_code)")
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
jdbcTemplate.update(
"UPDATE payment_card_detail SET easypay_provider_code = ? WHERE easypay_provider_code = ?",
null,
"null"
)
}
jdbcTemplate.execute("DROP INDEX idx_payment_card_detail_easypay ON payment_card_detail")
}
}
}
private suspend fun processPaymentAndDefaultDetail(reservations: List<ReservationWithPrice>) {
val paymentBatchArgs = mutableListOf<Array<Any>>()
val detailBatchArgs = mutableListOf<Array<Any>>()
reservations.forEachIndexed { idx, reservations ->
val id = tsidFactory.next()
val totalPrice = reservations.totalPrice
paymentBatchArgs.add(
arrayOf(
id,
reservations.reservationId,
PaymentType.NORMAL.name,
randomPaymentMethod(),
randomString(length = 64),
randomString(length = 20),
totalPrice,
PaymentStatus.DONE.name,
requestedAtCache,
approvedAtCache,
)
)
if (paymentBatchArgs.size >= 1_000) {
executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() }
}
val suppliedAmount: Int = (totalPrice * 0.9).toInt()
val vat: Int = (totalPrice - suppliedAmount)
detailBatchArgs.add(
arrayOf(
tsidFactory.next(),
id,
suppliedAmount,
vat
)
)
if (detailBatchArgs.size >= 1_000) {
executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() }
}
}
if (paymentBatchArgs.isNotEmpty()) {
executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() }
}
if (detailBatchArgs.isNotEmpty()) {
executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() }
}
}
private suspend fun processPaymentDetail(payments: List<PaymentWithMethods>) {
val transferBatchArgs = mutableListOf<Array<Any>>()
val cardBatchArgs = mutableListOf<Array<Any>>()
val easypayPrepaidBatchArgs = mutableListOf<Array<Any>>()
payments.forEach { payment ->
val totalPrice = payment.totalPrice
val randomDiscountAmount =
if (totalPrice < 100 || Math.random() < 0.8) 0 else ((100..totalPrice).random() / 100) * 100
val amount = totalPrice - randomDiscountAmount
when (payment.method) {
PaymentMethod.TRANSFER -> {
transferBatchArgs.add(
arrayOf(
payment.id,
BankCode.entries.random().name,
settlementStatus
)
)
if (transferBatchArgs.size >= 1_000) {
executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() }
}
}
PaymentMethod.EASY_PAY -> {
if (Math.random() <= 0.7) {
cardBatchArgs.add(
arrayOf(
payment.id,
CardIssuerCode.entries.random().name,
supportedCardType.random().name,
CardOwnerType.PERSONAL.name,
amount,
randomCardNumber(),
randomApprovalNumber(),
randomInstallmentPlanMonths(amount),
true,
EasyPayCompanyCode.entries.random().name,
randomDiscountAmount
)
)
if (cardBatchArgs.size >= 1_000) {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
} else {
easypayPrepaidBatchArgs.add(
arrayOf(
payment.id,
EasyPayCompanyCode.entries.random().name,
amount,
randomDiscountAmount,
)
)
if (easypayPrepaidBatchArgs.size >= 1_000) {
executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() }
}
}
}
PaymentMethod.CARD -> {
cardBatchArgs.add(
arrayOf(
payment.id,
CardIssuerCode.entries.random().name,
supportedCardType.random().name,
CardOwnerType.PERSONAL.name,
totalPrice,
randomCardNumber(),
randomApprovalNumber(),
randomInstallmentPlanMonths(totalPrice),
true,
"null",
0,
)
)
if (cardBatchArgs.size >= 1_000) {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
}
else -> return@forEach
}
}
if (transferBatchArgs.isNotEmpty()) {
executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() }
}
if (cardBatchArgs.isNotEmpty()) {
executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() }
}
if (easypayPrepaidBatchArgs.isNotEmpty()) {
executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() }
}
}
private suspend fun randomPaymentMethod(): String {
val random = Math.random()
return if (random <= 0.5) {
PaymentMethod.EASY_PAY.name
} else if (random <= 0.9) {
PaymentMethod.CARD.name
} else {
PaymentMethod.TRANSFER.name
}
}
private suspend fun randomCardNumber(): String {
return "${(10000000..99999999).random()}****${(100..999).random()}*"
}
private suspend fun randomApprovalNumber(): String {
return "${(10000000..99999999).random()}"
}
private suspend fun randomInstallmentPlanMonths(amount: Int): Int {
return if (amount < 50_000 || Math.random() < 0.9) {
0
} else {
(1..6).random()
}
}
}
fun randomKoreanName(): String {
val lastNames = listOf(
"", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""
)
return "${lastNames.random()}${if (Math.random() < 0.1) randomKoreanWords(1) else randomKoreanWords(2)}"
}
fun randomKoreanWords(length: Int = 1): String {
val words = listOf(
"", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "",
"", "", "", "", "",
)
return (1..length).joinToString("") { words.random() }
}

View File

@ -0,0 +1,236 @@
package roomescape.data
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import roomescape.common.config.next
import roomescape.store.infrastructure.persistence.StoreStatus
import roomescape.supports.randomPhoneNumber
import roomescape.supports.tsidFactory
import java.io.File
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.random.Random
const val BASE_DIR = "data"
const val PARSED_REGION_POPULATION_FILE = "$BASE_DIR/region_population.txt"
const val REGION_SQL_FILE = "data/region.sql"
const val MIN_POPULATION_FOR_PER_STORE = 200_000
/**
* 행안부 202508 인구 동향 데이터 사용( /data/population.xlsx )
*/
class PopulationDataSqlParser() {
val regionCodePattern = Regex("^[0-9]{10}$")
// 인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다.
fun createParsedRegionPopulationFiles() {
val populationXlsx = XSSFWorkbook(File("data/population.xlsx"))
val sheet = populationXlsx.getSheetAt(0)
val allRegion = mutableListOf<List<String>>()
val regionsMoreThanMinPopulation = mutableListOf<List<String?>>()
sheet.rowIterator().forEach { row ->
val regionCode = row.getCell(0)?.stringCellValue ?: return@forEach
if (regionCodePattern.matches(regionCode).not()) {
return@forEach
}
val sidoCode = regionCode.substring(0, 2)
val sigunguCode = regionCode.substring(2, 5)
val population = row.getCell(2).stringCellValue.replace(",", "")
if (Regex("^[0-9]+$").matches(population).not()) {
return@forEach
}
val regionName = row.getCell(1).stringCellValue
if (!regionName.trim().contains(" ")) {
return@forEach
}
val parts = regionName.split(" ")
if (parts.size < 2) {
return@forEach
}
val sidoName = parts[0].trim()
val sigunguName = parts[1].trim()
val populationInt = population.toInt()
if (populationInt <= 0) {
return@forEach
}
if (populationInt >= MIN_POPULATION_FOR_PER_STORE) {
regionsMoreThanMinPopulation.add(
listOf(
regionCode,
sidoCode,
sigunguCode,
sidoName,
sigunguName,
population
)
)
}
allRegion.add(listOf(regionCode, sidoCode, sigunguCode, sidoName, sigunguName))
}
regionsMoreThanMinPopulation.filter {
val sidoName = it[3]
val sigunguName = it[4]
val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName }
!((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2])
}.mapIndexed { idx, values ->
"${values[0]}, ${values[1]}, ${values[2]}, ${values[3]}, ${values[4]}, ${values[5]}"
}.joinToString(separator = "\n").also {
File(PARSED_REGION_POPULATION_FILE).writeText(it)
}
allRegion.distinctBy { it[1] to it[2] }.filter {
val sidoName = it[3]
val sigunguName = it[4]
val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName }
!((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2])
}.joinToString(
prefix = "INSERT INTO region(code, sido_code, sigungu_code, sido_name, sigungu_name) VALUES ",
separator = ",\n"
) { region ->
"('${region[0]}', '${region[1]}', '${region[2]}', '${region[3]}', '${region[4]}')"
}.also {
File(REGION_SQL_FILE).writeText("${it};")
}
}
}
/**
* PopulationDataSqlParser에서 전처리된 지역 + 인구 정보를 이용하여 Store 초기 데이터 SQL 생성
*/
class StoreDataInitializer {
val positiveWords = listOf(
"사랑", "행복", "희망", "감사", "기쁨", "미소", "축복", "선물", "평화",
"열정", "미래", "자유", "도전", "지혜", "행운"
)
fun createStoreDataSqlFile(creatableAdminIds: List<String>): File {
val regions = initializeRegionWithStoreCount()
val usedStoreName = mutableListOf<String>()
val usedBusinessRegNums = mutableListOf<String>()
val storeSqlRows = mutableListOf<String>()
val storeDataRows = mutableListOf<String>()
val storeIds = mutableListOf<Long>()
regions.forEachIndexed { idx, region ->
for (i in 0..region.storeCount) {
var address: String
var storeName: String
do {
val randomPositiveWord = positiveWords.random()
storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}"
address =
"${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${Random.nextInt(1, 10)}${Random.nextInt(1, 100)}"
} while (usedStoreName.contains(storeName))
usedStoreName.add(storeName)
val contact = randomPhoneNumber()
var businessRegNum: String
do {
businessRegNum = generateBusinessRegNum()
} while (usedBusinessRegNums.contains(businessRegNum))
usedBusinessRegNums.add(businessRegNum)
val createdAt = randomLocalDateTime()
val updatedAt = createdAt
val id: Long = tsidFactory.next().also { storeIds.add(it) }
val createdBy = creatableAdminIds.random()
storeSqlRows.add(
"(${id}, '$storeName', '$address', '$contact', '$businessRegNum', '${region.regionCode}', '${StoreStatus.ACTIVE.name}', '$createdAt', '${createdBy}', '$updatedAt', '${createdBy}')"
)
storeDataRows.add(
"$id, $storeName, $address, $contact, $businessRegNum, ${region.regionCode}, ${StoreStatus.ACTIVE.name}, $createdAt, $createdBy, $updatedAt, $createdBy"
)
}
}
File("$BASE_DIR/store_data.txt").also {
if (it.exists()) { it.delete() }
}.writeText(
storeDataRows.joinToString("\n")
)
return File("$BASE_DIR/store_data.sql").also {
if (it.exists()) { it.delete() }
StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, status, created_at, created_by, updated_at, updated_by) VALUES ")
.append(storeSqlRows.joinToString(",\n"))
.append(";")
.toString()
.also { sql -> it.writeText(sql) }
}
}
}
private fun parseSigunguName(sigunguName: String): String {
if (sigunguName.length == 2) {
return sigunguName
}
return sigunguName.substring(0, sigunguName.length - 1)
}
private fun randomLocalDateTime(): String {
val year = Random.nextInt(2020, 2024)
val month = Random.nextInt(1, 13)
val day = when (month) {
2 -> Random.nextInt(1, 29)
else -> Random.nextInt(1, 31)
}
val hour = Random.nextInt(9, 19)
val minute = Random.nextInt(0, 60)
val second = Random.nextInt(0, 60)
return LocalDateTime.of(year, month, day, hour, minute, second)
.atZone(ZoneId.systemDefault())
.toOffsetDateTime()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"))
}
private fun generateBusinessRegNum(): String {
val part1 = Random.nextInt(100, 1000)
val part2 = Random.nextInt(10, 100)
val part3 = Random.nextInt(10000, 100000)
return "$part1-$part2-$part3"
}
private fun initializeRegionWithStoreCount(): List<RegionWithStoreCount> {
return File(PARSED_REGION_POPULATION_FILE).also {
if (it.exists().not()) {
PopulationDataSqlParser().createParsedRegionPopulationFiles()
}
}.readText().lines().map {
val parts = it.split(", ")
val regionCode = parts[0]
val sidoName = parts[3]
val sigunguName = parts[4]
val storeCount: Int = (parts[5].toInt() / MIN_POPULATION_FOR_PER_STORE)
RegionWithStoreCount(
regionCode = regionCode,
sidoName = sidoName,
sigunguName = sigunguName,
storeCount = storeCount
)
}
}
data class RegionWithStoreCount(
val regionCode: String,
val sidoName: String,
val sigunguName: String,
var storeCount: Int
)

View File

@ -1,130 +0,0 @@
package roomescape.data
import io.kotest.core.spec.style.StringSpec
import roomescape.common.config.next
import roomescape.supports.randomPhoneNumber
import roomescape.supports.tsidFactory
import java.io.File
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.random.Random
/**
* DataParser에서 전처리된 지역 + 인구 정보를 이용하여 Store 초기 데이터 생성
*/
class StoreDataInitializer : StringSpec({
val positiveWords = listOf(
"사랑", "행복", "희망", "감사", "기쁨", "미소", "축복", "선물", "평화",
"열정", "미래", "자유", "도전", "지혜", "행운"
)
"초기 매장 데이터를 준비한다.".config(enabled = false) {
val regions = initializeRegionWithStoreCount()
val usedStoreName = mutableListOf<String>()
val usedBusinessRegNums = mutableListOf<String>()
val storeSqlRows = mutableListOf<String>()
val storeDataRows = mutableListOf<String>()
val storeIds = mutableListOf<Long>()
regions.forEachIndexed { idx, region ->
for (i in 0..region.storeCount) {
var address: String
var storeName: String
do {
val randomPositiveWord = positiveWords.random()
storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}"
address =
"${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${Random.nextInt(1, 10)}${Random.nextInt(1, 100)}"
} while (usedStoreName.contains(storeName))
usedStoreName.add(storeName)
val contact = randomPhoneNumber()
var businessRegNum: String
do {
businessRegNum = generateBusinessRegNum()
} while (usedBusinessRegNums.contains(businessRegNum))
usedBusinessRegNums.add(businessRegNum)
val createdAt = randomLocalDateTime()
val updatedAt = createdAt
val id: Long = tsidFactory.next().also { storeIds.add(it) }
storeSqlRows.add(
"(${id}, '$storeName', '$address', '$contact', '$businessRegNum', '${region.regionCode}', '$createdAt', '$updatedAt')"
)
storeDataRows.add(
"$id, $storeName, $address, $contact, $businessRegNum, ${region.regionCode}, $createdAt, $updatedAt"
)
}
}
StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, created_at, updated_at) VALUES ")
.append(storeSqlRows.joinToString(",\n"))
.append(";")
.toString()
.also { File("$BASE_DIR/store_data.sql").writeText(it) }
File("$BASE_DIR/store_data.txt").writeText(
storeDataRows.joinToString("\n")
)
}
})
private fun parseSigunguName(sigunguName: String): String {
if (sigunguName.length == 2) {
return sigunguName
}
return sigunguName.substring(0, sigunguName.length - 1)
}
private fun randomLocalDateTime(): String {
val year = Random.nextInt(2020, 2024)
val month = Random.nextInt(1, 13)
val day = when (month) {
2 -> Random.nextInt(1, 29)
else -> Random.nextInt(1, 31)
}
val hour = Random.nextInt(9, 19)
val minute = Random.nextInt(0, 60)
val second = Random.nextInt(0, 60)
return LocalDateTime.of(year, month, day, hour, minute, second)
.atZone(ZoneId.systemDefault())
.toOffsetDateTime()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"))
}
private fun generateBusinessRegNum(): String {
val part1 = Random.nextInt(100, 1000)
val part2 = Random.nextInt(10, 100)
val part3 = Random.nextInt(10000, 100000)
return "$part1-$part2-$part3"
}
private fun initializeRegionWithStoreCount(): List<RegionWithStoreCount> {
return File(PARSED_REGION_POPULATION_FILE).readText().lines().map {
val parts = it.split(", ")
val regionCode = parts[0]
val sidoName = parts[3]
val sigunguName = parts[4]
val storeCount: Int = (parts[5].toInt() / MIN_POPULATION_FOR_PER_STORE)
RegionWithStoreCount(
regionCode = regionCode,
sidoName = sidoName,
sigunguName = sigunguName,
storeCount = storeCount
)
}
}
data class RegionWithStoreCount(
val regionCode: String,
val sidoName: String,
val sigunguName: String,
var storeCount: Int
)

View File

@ -7,9 +7,7 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import roomescape.auth.exception.AuthErrorCode
import roomescape.common.config.next
import roomescape.common.exception.CommonErrorCode
import roomescape.common.util.DateUtils
import roomescape.payment.infrastructure.common.BankCode
import roomescape.payment.infrastructure.common.CardIssuerCode
import roomescape.payment.infrastructure.common.EasyPayCompanyCode
@ -19,7 +17,6 @@ import roomescape.reservation.infrastructure.persistence.CanceledReservationRepo
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.MostReservedThemeIdListResponse
import roomescape.reservation.web.ReservationCancelRequest
import roomescape.reservation.web.ReservationOverviewResponse
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
@ -28,8 +25,6 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.supports.*
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.toEntity
import roomescape.user.infrastructure.persistence.UserEntity
import java.time.LocalDate
import java.time.LocalTime
@ -566,24 +561,6 @@ class ReservationApiTest(
)
}
}
context("가장 많이 예약된 테마 ID를 조회한다.") {
test("정상 응답") {
val expectedResult: MostReservedThemeIdListResponse = initializeForPopularThemeTest()
runTest(
on = {
get("/reservations/popular-themes?count=10")
},
expect = {
statusCode(HttpStatus.OK.value())
}
).also {
val result: List<Long> = it.extract().path("data.themeIds")
result shouldBe expectedResult.themeIds
}
}
}
}
fun runDetailRetrieveTest(
@ -605,76 +582,4 @@ class ReservationApiTest(
it.extract().path<Long>("data.user.id") shouldBe reservation.userId
}.extract().path("data.payment")
}
private fun initializeForPopularThemeTest(): MostReservedThemeIdListResponse {
val user: UserEntity = testAuthUtil.defaultUser()
val themeIds: List<Long> = (1..5).map {
themeRepository.save(ThemeFixture.createRequest.copy().toEntity(id = tsidFactory.next())).id
}
val store = dummyInitializer.createStore()
// 첫 번째 테마: 유효한 2개 예약
(1L..2L).forEach {
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it),
themeId = themeIds[0],
)
)
}
// 두 번째 테마: 유효한 1개 예약
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()),
themeId = themeIds[1],
)
)
// 세 번째 테마: 유효한 3개 예약
(1L..3L).forEach {
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it),
themeId = themeIds[2],
)
)
}
// 네 번째 테마: Pending 상태인 3개 예약 -> 집계되지 않음.
(1L..3L).forEach {
dummyInitializer.createPendingReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it),
themeId = themeIds[3],
)
)
}
// 다섯 번째 테마: 이번주의 확정 예약 -> 집계되지 않음.
(1L..3L).forEach { i ->
val thisMonday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(8)
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = thisMonday.plusDays(i),
themeId = themeIds[4],
)
)
}
// 조회 예상 결과: 세번째, 첫번째, 두번째 테마 순서
return MostReservedThemeIdListResponse(listOf(themeIds[2], themeIds[0], themeIds[1]))
}
}

View File

@ -49,7 +49,8 @@ class DummyInitializer(
contact: String = randomPhoneNumber(),
businessRegNum: String = randomBusinessRegNum(),
regionCode: String = "1111000000",
status: StoreStatus = StoreStatus.ACTIVE
status: StoreStatus = StoreStatus.ACTIVE,
createdBy: Long = 0L,
): StoreEntity {
return StoreEntity(
id = id,
@ -59,7 +60,10 @@ class DummyInitializer(
businessRegNum = businessRegNum,
regionCode = regionCode,
status = status
).also {
).apply {
this.createdBy = createdBy
this.updatedBy = createdBy
}.also {
storeRepository.save(it)
}
}

View File

@ -251,29 +251,29 @@ object PaymentFixture {
fun cardDetail(
amount: Int,
issuerCode: CardIssuerCode = CardIssuerCode.SHINHAN,
cardType: CardType = CardType.CREDIT,
ownerType: CardOwnerType = CardOwnerType.PERSONAL,
issuerCode: CardIssuerCode = CardIssuerCode.entries.random(),
cardType: CardType = CardType.entries.random(),
ownerType: CardOwnerType = CardOwnerType.entries.random(),
installmentPlanMonths: Int = 0,
): CardDetail = CardDetail(
issuerCode = issuerCode,
number = "429335*********",
number = "${(400000..500000).random()}*********",
amount = amount,
cardType = cardType,
ownerType = ownerType,
isInterestFree = false,
approveNo = "1828382",
approveNo = "${(1000000..9999999).random()}",
installmentPlanMonths = installmentPlanMonths
)
fun easypayDetail(
amount: Int,
provider: EasyPayCompanyCode = EasyPayCompanyCode.TOSSPAY,
provider: EasyPayCompanyCode = EasyPayCompanyCode.entries.random(),
discountAmount: Int = 0
): EasyPayDetail = EasyPayDetail(provider, amount, discountAmount)
fun transferDetail(
bankCode: BankCode = BankCode.SHINHAN,
bankCode: BankCode = BankCode.entries.random(),
settlementStatus: String = "COMPLETED"
): TransferDetail = TransferDetail(
bankCode = bankCode,

View File

@ -29,8 +29,12 @@ object KotestConfig : AbstractProjectConfig() {
@Import(TestConfig::class)
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class FunSpecSpringbootTest : FunSpec({
extension(DatabaseCleanerExtension())
abstract class FunSpecSpringbootTest(
enableCleanerExtension: Boolean = true,
) : FunSpec({
if (enableCleanerExtension) {
extension(DatabaseCleanerExtension())
}
}) {
@Autowired
private lateinit var userRepository: UserRepository

View File

@ -12,14 +12,15 @@ inline fun <T> initialize(name: String, block: () -> T): T {
fun randomPhoneNumber(): String {
val prefix = "010"
val middle = (1000..9999).random()
val last = (1000..9999).random()
val middle = (1..4).map { (0..9).random() }.joinToString("")
val last = (1..4).map { (0..9).random() }.joinToString("")
return "$prefix$middle$last"
}
fun randomString(): String {
fun randomString(length: Int = 10): String {
val chars = ('a'..'z') + ('0'..'9')
return (1..10)
return (1..length)
.map { chars.random() }
.joinToString("")
}

View File

@ -1,41 +1,24 @@
package roomescape.theme
import io.kotest.matchers.collections.shouldContainInOrder
import io.kotest.matchers.collections.shouldHaveSize
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import roomescape.common.config.next
import roomescape.common.util.DateUtils
import roomescape.supports.*
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.web.ThemeIdListRequest
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.toEntity
import roomescape.user.infrastructure.persistence.UserEntity
import java.time.LocalDate
class ThemeApiTest : FunSpecSpringbootTest() {
class ThemeApiTest(
private val themeRepository: ThemeRepository
) : FunSpecSpringbootTest() {
init {
context("입력된 모든 ID에 대한 테마를 조회한다.") {
test("정상 응답 + 없는 테마가 있으면 생략한다.") {
val themeIds: List<Long> = initialize("목록 조회를 위한 3개의 테마 생성 및 일부 존재하지 않는 ID 추가") {
val themeIds = mutableListOf(INVALID_PK)
(1..3).forEach { _ ->
themeIds.add(dummyInitializer.createTheme().id)
}
themeIds
}
runTest(
using = {
body(ThemeIdListRequest(themeIds))
},
on = {
post("/themes/batch")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.themes.size()", equalTo(themeIds.filter { it != INVALID_PK }.size))
}
)
}
}
context("ID로 테마 정보를 조회한다.") {
test("정상 응답") {
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
@ -68,5 +51,97 @@ class ThemeApiTest : FunSpecSpringbootTest() {
)
}
}
context("인기 테마를 조회한다.") {
test("정상 응답") {
val expectedResult: List<Long> = initializeForPopularThemeTest()
runTest(
on = {
get("/themes/most-reserved?count=10")
},
expect = {
statusCode(HttpStatus.OK.value())
}
).also { res ->
val response: List<LinkedHashMap<String, Any>> = res.extract().path("data.themes")
response shouldHaveSize expectedResult.size
response.map { it["id"] as Long }.shouldContainInOrder(expectedResult)
}
}
}
}
private fun initializeForPopularThemeTest(): List<Long> {
val user: UserEntity = testAuthUtil.defaultUser()
val themeIds: List<Long> = (1..5).map {
themeRepository.save(ThemeFixture.createRequest.copy().toEntity(id = tsidFactory.next())).id
}
val store = dummyInitializer.createStore()
// 첫 번째 테마: 유효한 2개 예약
(1L..2L).forEach {
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it),
themeId = themeIds[0],
)
)
}
// 두 번째 테마: 유효한 1개 예약
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()),
themeId = themeIds[1],
)
)
// 세 번째 테마: 유효한 3개 예약
(1L..3L).forEach {
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it),
themeId = themeIds[2],
)
)
}
// 네 번째 테마: Pending 상태인 3개 예약 -> 집계되지 않음.
(1L..3L).forEach {
dummyInitializer.createPendingReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it),
themeId = themeIds[3],
)
)
}
// 다섯 번째 테마: 이번주의 확정 예약 -> 집계되지 않음.
(1L..3L).forEach { i ->
val thisMonday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(8)
dummyInitializer.createConfirmReservation(
user = user,
storeId = store.id,
scheduleRequest = ScheduleFixture.createRequest.copy(
date = thisMonday.plusDays(i),
themeId = themeIds[4],
)
)
}
// 조회 예상 결과: 세번째, 첫번째, 두번째 테마 순서
return listOf(themeIds[2], themeIds[0], themeIds[1])
}
}

View File

@ -0,0 +1,10 @@
spring:
datasource:
hikari:
jdbc-url: jdbc:mysql://localhost:3306/roomescape
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: init
sql:
init:
mode: never