diff --git a/build.gradle b/build.gradle index 56a8c9b..70d7bf1 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(17) } } @@ -25,7 +25,7 @@ ext { dependencies { implementation 'com.github.Miche-Let:common:dev-SNAPSHOT' implementation 'com.github.Miche-Let:common-auth-webmvc:0.1.5' - implementation 'com.github.Miche-Let:common-auth-feign:0.1.1' + implementation 'com.github.Miche-Let:common-auth-feign:0.1.2' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/http-tests/LOCAL_TEST_GUIDE.md b/http-tests/LOCAL_TEST_GUIDE.md new file mode 100644 index 0000000..d347896 --- /dev/null +++ b/http-tests/LOCAL_TEST_GUIDE.md @@ -0,0 +1,139 @@ +# Local HTTP Client Test Guide + +IntelliJ HTTP Client 기반 로컬 통합 테스트 가이드. +모든 서비스가 Eureka에 등록되고, FeignClient는 `lb://` 로드밸런싱으로 내부 서비스를 호출합니다. + +단, 이는 예약 생성 시나리오, 타 시나리오상 예약 서비스가 활용되는 내부 api 만한정하여 작성함. + +--- + +## 환경 설정 + +### 서비스 설정 및 Java 코드 변경 + +# waiting-service + + application.yml + - Eureka 클라이언트 설정 추가 (서비스 등록 및 검색) + - port 19200 + build.gradle + - Spring Cloud Netflix Eureka Client 의존성 추가 + +# user-service + + application.yml 설정 + + eureka: + client: + service-url: + defaultZone: http://localhost:${EUREKA_PORT:8761}/eureka/ + + spring: + datasource: + url: jdbc:postgresql://localhost:${POSTGRES_PORT:5432}/${POSTGRES_DB:michelet_db} + username: ${POSTGRES_USER:admin} + password: ${POSTGRES_PASSWORD:admin} + driver-class-name: org.postgresql.Driver + data: + redis: + host: localhost + port: ${REDIS_PORT:6379} + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + default_schema: user_service + format_sql: true + show-sql: true + +# timeslot-service + + application.yml 설정 + + eureka: + client: + service-url: + defaultZone: http://localhost:${EUREKA_PORT:8761}/eureka/ + + spring: + datasource: + url: jdbc:postgresql://localhost:${POSTGRES_PORT:5432}/${POSTGRES_DB:michelet_db} + username: ${POSTGRES_USER:admin} + password: ${POSTGRES_PASSWORD:admin} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + default_schema: timeslot_service + format_sql: true + show-sql: true + +# restaurant-service + + application.yml 설정 + + eureka: + client: + service-url: + defaultZone: http://localhost:${EUREKA_PORT:8761}/eureka/ + + spring: + datasource: + url: jdbc:postgresql://localhost:${POSTGRES_PORT:5432}/${POSTGRES_DB:michelet_db} + username: ${POSTGRES_USER:admin} + password: ${POSTGRES_PASSWORD:admin} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + default_schema: restaurant_service + format_sql: true + show-sql: true + +--- + +## 실행 순서 + +### 1. 인프라 및 DB 준비 + +```bash +# 1. 인프라 시작 (PostgreSQL, Redis 등) +cd infra && docker-compose -f docker-compose.infra.yml up -d + +# 2. 테스트 데이터 리셋 및 시드 투입 (필수) +docker exec -i db psql -U admin -d michelet_db < http-tests/sql/reset_test_data.sql +docker exec -i db psql -U admin -d michelet_db < http-tests/sql/seed_test_data.sql +``` + +### 2. 서비스 기동 + +`start-local.sh`를 사용하거나 별도 터미널에서 순서대로 기동합니다. (Eureka → User → Restaurant → Timeslot → Waiting → Reservation → Gateway) +실행후 Eureka 대시보드(http://localhost:8761)에서 모든 서비스가 `UP` 상태인지 확인합니다. + +### 3. 테스트 실행 (IntelliJ HTTP Client) + +#### **Scenario 1: 소비자 예약 플로우 (분리 실행)** + +1. **`scenario1a_setup.http`**: 회원가입부터 대기열 등록(STEP 6)까지 한 번에 실행. +2. **10~20초 대기**: 스케줄러가 대기 토큰을 `ACTIVE`로 바꿀 때까지 기다립니다. +3. **`scenario1b_reservation.http`**: + - **STEP 7**을 먼저 실행하여 `ACTIVE` 상태를 확인합니다. + - 활성화가 확인되면 나머지 단계(예약 생성 등)를 진행합니다. + +#### **Scenario 2: 체크인 플로우 (`scenario2_checkin.http`)** + +- 시드 데이터의 고정 예약(`b2c3d4e5-...`)을 사용하여 체크인(`COMPLETED`)을 테스트합니다. +- `http-client.env.json`에 미리 설정된 ID를 사용하므로 바로 실행 가능합니다. + +#### **Scenario 3: 방문 이력 검증 (`scenario3_history_check.http`)** + +- 시드 데이터의 고정 예약(`a1b2c3d4-...`)을 사용합니다. +- 이 데이터는 항상 `CONFIRMED` 상태를 유지하므로, 시나리오 2 실행 여부와 관계없이 항상 성공합니다. diff --git a/http-tests/http-client.env.json b/http-tests/http-client.env.json new file mode 100644 index 0000000..aea7d91 --- /dev/null +++ b/http-tests/http-client.env.json @@ -0,0 +1,18 @@ +{ + "local": { + "gateway": "http://localhost:19000", + "reservationServiceUrl": "http://localhost:19500", + "accessToken": "", + "userId": "ffa8b6c9-cc64-41e8-a1b0-2b0622489b78", + "restaurantId": "2589a05e-db8f-4e62-bb3d-22ce85b750a3", + "timeSlotId": "", + "slotStartTime": "", + "courseId": "7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b", + "courseUnitPrice": 80000, + "waitingId": "", + "waitingToken": "", + "reservationId": "b2c3d4e5-f6a7-8901-bcde-f01234567891", + "reservationIdForHistory": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "targetDate": "2026-06-14" + } +} diff --git a/http-tests/scenario1a_setup.http b/http-tests/scenario1a_setup.http new file mode 100644 index 0000000..f57caa4 --- /dev/null +++ b/http-tests/scenario1a_setup.http @@ -0,0 +1,129 @@ +### Scenario 1-A: Consumer Setup (SignUp -> Waiting) +### 사전 조건: 인프라 및 모든 서비스 기동 완료 +### 이 파일은 대기열 등록(STEP 6)까지만 수행합니다. 이후 대기 상태가 ACTIVE가 되면 Scenario 1-B를 실행하세요. + +### [STEP 1] 회원가입 +POST {{gateway}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser01", + "password": "Test123!@#", + "name": "테스트유저", + "email": "testuser01@example.com", + "phone": "010-1234-5678", + "userRole": "USER" +} + +> {% + client.test("회원가입 성공", function() { + client.assert(response.status === 201, "Expected 201 Created but got " + response.status); + if (response.body && response.body.data) { + client.assert(response.body.data.userId !== undefined, "userId 반환 확인"); + } + }); + if (response.body && response.body.data) { + client.global.set("userId", response.body.data.userId); + } +%} + +### + +### [STEP 2] 로그인 → accessToken 추출 +POST {{gateway}}/api/v1/auth/login +Content-Type: application/json + +{ + "loginId": "testuser01", + "password": "Test123!@#" +} + +> {% + client.test("로그인 성공", function() { + client.assert(response.status === 200, "Expected 200 OK but got " + response.status); + if (response.body && response.body.data) { + client.assert(response.body.data.accessToken !== undefined, "accessToken 반환 확인"); + } + }); + if (response.body && response.body.data) { + client.global.set("accessToken", response.body.data.accessToken); + } +%} + +### + +### [STEP 3] 레스토랑 목록 검색 +GET {{gateway}}/api/v1/restaurants?page=0&size=5 +Authorization: Bearer {{accessToken}} + +> {% + client.test("레스토랑 목록 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK but got " + response.status); + if (response.body && response.body.data && response.body.data.content) { + client.assert(response.body.data.content.length > 0, "레스토랑 1개 이상 존재 확인"); + } + }); + if (response.body && response.body.data && response.body.data.content && response.body.data.content.length > 0) { + var first = response.body.data.content[0]; + client.global.set("restaurantId", first.restaurantId); + } +%} + +### + +### [STEP 4] 레스토랑 상세 조회 (코스 목록 포함) +GET {{gateway}}/api/v1/restaurants/{{restaurantId}} +Authorization: Bearer {{accessToken}} + +> {% + client.test("레스토랑 상세 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK"); + client.assert(response.body.data.restaurantId !== undefined, "restaurantId 확인"); + }); +%} + +### + +### [STEP 5] 코스 목록 조회 → courseId, 단가 추출 +GET {{gateway}}/api/v1/restaurants/{{restaurantId}}/courses +Authorization: Bearer {{accessToken}} + +> {% + client.test("코스 목록 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK but got " + response.status); + if (response.body && response.body.data) { + client.assert(response.body.data.length > 0, "코스 1개 이상 존재 확인"); + } + }); + if (response.body && response.body.data && response.body.data.length > 0) { + var course = response.body.data[0]; + client.global.set("courseId", course.courseId); + client.global.set("courseUnitPrice", course.price); + } +%} + +### + +### [STEP 6] 대기열 등록 → waitingId, waitingToken 추출 +POST {{gateway}}/api/v1/waitings +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "userId": "{{userId}}", + "restaurantId": "{{restaurantId}}" +} + +> {% + client.test("대기열 등록 성공", function() { + client.assert(response.status === 201, "Expected 201 Created but got " + response.status); + if (response.body && response.body.data) { + client.assert(response.body.data.waitingId !== undefined, "waitingId 반환 확인"); + client.assert(response.body.data.token !== undefined, "token 반환 확인"); + } + }); + if (response.body && response.body.data) { + client.global.set("waitingId", response.body.data.waitingId); + client.global.set("waitingToken", response.body.data.token); + } +%} diff --git a/http-tests/scenario1b_reservation.http b/http-tests/scenario1b_reservation.http new file mode 100644 index 0000000..e410d60 --- /dev/null +++ b/http-tests/scenario1b_reservation.http @@ -0,0 +1,126 @@ +### Scenario 1-B: Consumer Reservation (Waiting Check -> Reservation) +### 사전 조건: Scenario 1-A 실행 완료 (waitingToken 획득 상태) +### 이 파일은 대기 상태 확인부터 실제 예약 생성 및 조회까지 수행합니다. + +### [STEP 7] 대기 상태 확인 (WAIT 10 SECONDS IF STATUS IS 'WAITING') +### 주의: 스케줄러가 10초마다 WAITING → ACTIVE 전환함 +### 만약 status가 WAITING 이라면, 10초 후 이 단계를 다시 실행하세요. +GET {{gateway}}/api/v1/waitings/status?token={{waitingToken}} +Authorization: Bearer {{accessToken}} + +> {% + client.test("대기 상태 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK but got " + response.status); + client.assert(response.body && response.body.data, "응답 본문(data)이 없습니다."); + var status = response.body.data.status; + client.assert(status === "ACTIVE", "아직 ACTIVE가 아닙니다 (현재: " + status + "). 10초 후 이 STEP을 다시 실행하세요."); + }); +%} + +### + +### [STEP 8] 월별 예약 가능 달력 조회 (2026년 6월) +GET {{gateway}}/api/v1/restaurants/{{restaurantId}}/time-slots/calendar?year=2026&month=6 +Authorization: Bearer {{accessToken}} + +> {% + client.test("월별 달력 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK"); + }); + client.test("예약 가능 날짜 존재 확인", function() { + var slots = response.body.data; + var opened = slots ? slots.filter(function(s) { return s.status === "OPENED"; }) : []; + client.assert(opened.length > 0, "OPENED 상태의 날짜가 없습니다. 시드 데이터를 확인하세요."); + }); + var slots = response.body.data; + var opened = slots ? slots.filter(function(s) { return s.status === "OPENED"; }) : []; + if (opened.length > 0) { + client.global.set("targetDate", opened[0].date); + client.log("예약 가능 날짜: " + opened[0].date); + } +%} + +### + +### [STEP 9] 특정 날짜 타임슬롯 목록 조회 → timeSlotId, slotStartTime 추출 +GET {{gateway}}/api/v1/restaurants/{{restaurantId}}/time-slots?targetDate={{targetDate}} +Authorization: Bearer {{accessToken}} + +> {% + client.test("타임슬롯 목록 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK"); + client.assert(response.body.data.length > 0, "타임슬롯 1개 이상 존재 확인"); + }); + client.test("예약 가능 슬롯 존재 확인", function() { + var available = response.body.data ? response.body.data.filter(function(s) { return s.remainingCapacity > 0; }) : []; + client.assert(available.length > 0, "잔여 용량이 있는 타임슬롯이 없습니다. 시드 데이터를 확인하세요."); + }); + var available = response.body.data ? response.body.data.filter(function(s) { return s.remainingCapacity > 0; }) : []; + if (available.length > 0) { + client.global.set("timeSlotId", available[0].timeSlotId); + client.global.set("slotStartTime", available[0].startTime); + client.log("선택된 타임슬롯: " + available[0].timeSlotId + " (" + available[0].startTime + ")"); + } +%} + +### + +### [STEP 10] 예약 생성 +### 전제: STEP 7에서 대기 상태가 ACTIVE 인 것을 확인한 후 실행 +POST {{gateway}}/api/v1/reservations +Authorization: Bearer {{accessToken}} +X-Waiting-Token: {{waitingToken}} +Content-Type: application/json + +{ + "restaurantId": "{{restaurantId}}", + "timeSlotId": "{{timeSlotId}}", + "reservedDate": "{{targetDate}}", + "slotStartTime": "{{slotStartTime}}", + "guestCount": 2, + "courses": [ + { + "courseId": "{{courseId}}", + "quantity": 2, + "unitPrice": {{courseUnitPrice}} + } + ] +} + +> {% + client.test("예약 생성 성공", function() { + client.assert(response.status === 201, "Expected 201 Created but got " + response.status); + if (response.body && response.body.data) { + client.assert(response.body.data.reservationId !== undefined, "reservationId 반환 확인"); + } + }); + if (response.body && response.body.data) { + client.global.set("reservationId", response.body.data.reservationId); + } +%} + +### + +### [STEP 11] 예약 상세 조회 +GET {{gateway}}/api/v1/reservations/{{reservationId}} +Authorization: Bearer {{accessToken}} + +> {% + client.test("예약 상세 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK"); + client.assert(response.body.data.status === "CONFIRMED", "예약 상태 CONFIRMED 확인"); + }); +%} + +### + +### [STEP 12] 내 예약 목록 조회 +GET {{gateway}}/api/v1/reservations?page=0&size=10 +Authorization: Bearer {{accessToken}} + +> {% + client.test("예약 목록 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK"); + client.assert(response.body.data.content.length > 0, "예약 1개 이상 존재 확인"); + }); +%} diff --git a/http-tests/scenario2_checkin.http b/http-tests/scenario2_checkin.http new file mode 100644 index 0000000..a3ac0ad --- /dev/null +++ b/http-tests/scenario2_checkin.http @@ -0,0 +1,44 @@ +### Scenario 2: Consumer Check-in Flow +### 사전 조건: Scenario 1 완료 (reservationId, userId, restaurantId가 env에 세팅된 상태) +### 내부 API 직접 호출 (gateway 미통과) - reservation-service 직접 접근 +### 실제 서비스 간 통신은 FeignClient(lb://)를 통해 Eureka 기반으로 이루어짐 +### + +### [STEP 1] 예약 존재 여부 확인 (Internal API) +### 특정 예약 ID + 유저 + 레스토랑 조합으로 존재 여부 검증 +GET {{reservationServiceUrl}}/internal/reservations/exists?reservationId={{reservationId}}&userId={{userId}}&restaurantId={{restaurantId}} + +> {% + client.test("예약 존재 여부 확인 성공", function() { + client.assert(response.status === 200, "Expected 200 OK but got " + response.status); + if (response.body && response.body.data) { + client.assert(response.body.data.exists === true, "예약 존재 확인 (exists=true)"); + } + }); +%} + +### + +### [STEP 2] 체크인 처리 (Internal API) +### 레스토랑 스태프가 고객 도착 시 호출 +PATCH {{reservationServiceUrl}}/internal/reservations/check-in +Content-Type: application/json + +{ + "reservationId": "{{reservationId}}", + "restaurantId": "{{restaurantId}}" +} + +> {% + client.test("체크인 성공", function() { + client.assert(response.status === 200, "Expected 200 OK but got " + response.status); + if (response.body && response.body.data) { + client.assert(response.body.data.status === "COMPLETED", "예약 상태 COMPLETED 확인"); + client.assert(response.body.data.checkedInAt !== null, "체크인 시각 기록 확인"); + } + }); + if (response.body && response.body.data) { + client.log("체크인 완료 - 상태: " + response.body.data.status); + client.log("체크인 시각: " + response.body.data.checkedInAt); + } +%} diff --git a/http-tests/scenario3_history_check.http b/http-tests/scenario3_history_check.http new file mode 100644 index 0000000..6cbdef9 --- /dev/null +++ b/http-tests/scenario3_history_check.http @@ -0,0 +1,27 @@ +### Scenario 3: Reservation History Check (방문 이력 검증) +### 사전 조건: seed_test_data.sql 실행 완료 (고정 예약 데이터 존재) +### 이 테스트는 시드 데이터로 삽입된 고정 ID를 사용하여 검증합니다. +### 활용 사례: 레스토랑 전용 콘텐츠(리뷰, 특별 메뉴 등)를 과거 방문자에게만 공개할 때 +### 내부 API 직접 호출 - reservation-service 직접 접근 + +### [STEP 1] 방문 이력 검증 (Internal API) +### 특정 유저가 특정 레스토랑에 예약한 이력이 있는지 확인 +### CONFIRMED/COMPLETED 상태 예약이 존재하면 exists=true 반환 +GET {{reservationServiceUrl}}/internal/reservations/verify?userId={{userId}}&restaurantId={{restaurantId}} + +> {% + client.test("예약 이력 조회 성공", function() { + client.assert(response.status === 200, "Expected 200 OK but got " + response.status); + }); + if (response.body && response.body.data) { + var data = response.body.data; + client.log("예약 이력 존재: " + data.exists); + if (data.exists) { + client.log("예약 ID: " + data.reservationId); + client.log("예약 날짜: " + data.reservationDate); + client.log("→ 방문 이력 있음: 레스토랑 전용 콘텐츠 접근 허용"); + } else { + client.log("→ 방문 이력 없음: 레스토랑 전용 콘텐츠 접근 차단"); + } + } +%} diff --git a/http-tests/sql/reset_test_data.sql b/http-tests/sql/reset_test_data.sql new file mode 100644 index 0000000..805bade --- /dev/null +++ b/http-tests/sql/reset_test_data.sql @@ -0,0 +1,38 @@ +-- ============================================================ +-- Miche-Let 테스트 데이터 초기화 (완전 리셋) +-- 기존 시드 데이터를 삭제하고 seed_test_data.sql을 재실행합니다. +-- +-- 실행 방법: +-- docker exec -i db psql -U admin -d michelet_db \ +-- < http-tests/sql/reset_test_data.sql +-- docker exec -i db psql -U admin -d michelet_db \ +-- < http-tests/sql/seed_test_data.sql +-- +-- 주의: 이 파일은 시드 데이터만 삭제합니다. +-- 다른 유저/레스토랑 데이터가 있으면 함께 지워질 수 있습니다. +-- ============================================================ + +-- 외래 키 의존 순서대로 삭제 (자식 → 부모) +DELETE FROM reservation_service.p_reservations +WHERE id IN ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'b2c3d4e5-f6a7-8901-bcde-f01234567891'); + +DELETE FROM timeslot_service.p_time_slot +WHERE restaurant_id = '2589a05e-db8f-4e62-bb3d-22ce85b750a3'; + +DELETE FROM restaurant_service.p_restaurant_course_menu +WHERE course_id = '7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b'; + +DELETE FROM restaurant_service.p_restaurant_course +WHERE course_id = '7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b'; + +DELETE FROM restaurant_service.p_restaurant +WHERE restaurant_id = '2589a05e-db8f-4e62-bb3d-22ce85b750a3'; + +-- owneruser01 삭제 (seed에서 다시 삽입됨) +DELETE FROM user_service.p_users +WHERE id = 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78'; + +-- 시나리오 실행 중 생성된 테스트 유저 삭제 (signup으로 생성되므로 UUID 미확정 → login_id로 삭제) +DELETE FROM user_service.p_users WHERE login_id IN ('testuser01', 'testuser02'); + +SELECT '초기화 완료 — seed_test_data.sql을 실행하세요.' AS "상태"; diff --git a/http-tests/sql/seed_test_data.sql b/http-tests/sql/seed_test_data.sql new file mode 100644 index 0000000..256207d --- /dev/null +++ b/http-tests/sql/seed_test_data.sql @@ -0,0 +1,298 @@ +-- ============================================================ +-- Miche-Let 로컬 테스트 시드 데이터 +-- 대상 DB: michelet_db (PostgreSQL) +-- 사전 조건: 모든 서비스를 최소 1회 기동하여 Hibernate DDL로 테이블 생성 완료 +-- +-- 실행 방법: +-- docker exec -i db psql -U admin -d michelet_db \ +-- < http-tests/sql/seed_test_data.sql +-- +-- 멱등성: ON CONFLICT DO NOTHING 처리 — 중복 실행 안전 +-- ============================================================ + +-- BCrypt 해시 함수(crypt, gen_salt) 사용을 위해 pgcrypto 활성화 +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- 시스템 생성자 UUID (시드 데이터용 더미 created_by) +-- created_by 컬럼이 NOT NULL이므로 시드 전용 UUID를 사용 +-- 실제 서비스에서는 AuditorConfig가 JWT에서 추출한 userId를 자동 주입함 + +-- ============================================================ +-- 1. 유저 (user_service.p_users) +-- 비밀번호: Test123!@# (BCrypt strength 10) +-- +-- ※ testuser01, testuser02는 HTTP 시나리오(signup 단계)가 직접 생성합니다. +-- 여기서 삽입하면 시나리오 STEP 1(회원가입)이 409 충돌로 실패합니다. +-- owneruser01만 사전 삽입합니다 (레스토랑의 owner_id 참조 필요). +-- ============================================================ +INSERT INTO user_service.p_users ( + id, + login_id, + password, + name, + email, + phone, + role, + status, + last_login_at, + created_at, + created_by, + updated_at, + updated_by, + deleted_at, + deleted_by +) VALUES ( + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', + 'owneruser01', + crypt('Test123!@#', gen_salt('bf', 10)), + '오너유저', + 'owneruser01@example.com', + '010-0000-0001', + 'OWNER', + 'ACTIVE', + NULL, + NOW(), + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', -- self-reference (seed 데이터) + NOW(), + NULL, + NULL, + NULL +) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 2. 레스토랑 (restaurant_service.p_restaurant) +-- ============================================================ +INSERT INTO restaurant_service.p_restaurant ( + restaurant_id, + owner_id, + name, + address, + phone, + description, + reservation_open_at, + avg_meal_duration_min, + status, + business_hours, + created_at, + created_by, + updated_at, + updated_by, + deleted_at, + deleted_by +) VALUES ( + '2589a05e-db8f-4e62-bb3d-22ce85b750a3', + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', + '테스트 레스토랑', + '서울시 강남구 테헤란로 123', + '02-1234-5678', + '로컬 통합 테스트용 레스토랑입니다.', + '09:00', + 60, + 'OPEN', + '화-일 18:00-22:00 (월요일 정기 휴무)', + NOW(), + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', + NOW(), + NULL, + NULL, + NULL +) +ON CONFLICT (restaurant_id) DO NOTHING; + +-- ============================================================ +-- 3. 코스 (restaurant_service.p_restaurant_course) +-- ============================================================ +INSERT INTO restaurant_service.p_restaurant_course ( + course_id, + restaurant_id, + name, + price, + menu_composition, + session_type, + status, + created_at, + created_by, + updated_at, + updated_by, + deleted_at, + deleted_by +) VALUES ( + '7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b', + '2589a05e-db8f-4e62-bb3d-22ce85b750a3', + '프리미엄 코스', + 80000, + '전채 → 생선 요리 → 메인 → 디저트', + 'DINNER', + 'AVAILABLE', + NOW(), + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', + NOW(), + NULL, + NULL, + NULL +) +ON CONFLICT (course_id) DO NOTHING; + +-- ============================================================ +-- 4. 코스 메뉴 (restaurant_service.p_restaurant_course_menu) +-- (course_id, sort_order) 유니크 제약으로 중복 방지 +-- ============================================================ +INSERT INTO restaurant_service.p_restaurant_course_menu ( + course_menu_id, + course_id, + course_part, + menu_name, + sort_order, + created_at, + created_by, + updated_at, + updated_by, + deleted_at, + deleted_by +) VALUES +( + gen_random_uuid(), + '7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b', + 'APPETIZER', '모듬 샐러드', 1, + NOW(), 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', NOW(), NULL, NULL, NULL +), +( + gen_random_uuid(), + '7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b', + 'FISH', '연어 카르파쵸', 2, + NOW(), 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', NOW(), NULL, NULL, NULL +), +( + gen_random_uuid(), + '7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b', + 'MAIN', '안심 스테이크', 3, + NOW(), 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', NOW(), NULL, NULL, NULL +), +( + gen_random_uuid(), + '7d376f47-8fb3-4be1-916f-4ee0e6ee8b3b', + 'DESSERT', '티라미수', 4, + NOW(), 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', NOW(), NULL, NULL, NULL +) +ON CONFLICT ON CONSTRAINT uk_restaurant_course_menu_course_id_sort_order DO NOTHING; + +-- ============================================================ +-- 5. 타임슬롯 (timeslot_service.p_time_slot) — 2026년 6월 전체 +-- 하루 4슬롯: 18:00 / 19:00 / 20:00 / 21:00 (1시간 단위) +-- 수용 10명, OPENED, version=0 +-- (restaurant_id, target_date, start_time) 유니크 제약으로 중복 방지 +-- ============================================================ +INSERT INTO timeslot_service.p_time_slot ( + time_slot_id, + restaurant_id, + target_date, + start_time, + end_time, + capacity, + remaining_capacity, + status, + version, + created_at, + created_by, + updated_at, + updated_by, + deleted_at, + deleted_by +) +SELECT + gen_random_uuid(), + '2589a05e-db8f-4e62-bb3d-22ce85b750a3'::UUID, + d::DATE, + t::TIME, + (t::TIME + INTERVAL '1 hour'), + 10, + 10, + 'OPENED', + 0, + NOW(), + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78'::UUID, + NOW(), + NULL, + NULL, + NULL +FROM + generate_series('2026-06-01'::DATE, '2026-06-30'::DATE, '1 day') AS d, + (VALUES ('18:00'), ('19:00'), ('20:00'), ('21:00')) AS times(t) +ON CONFLICT ON CONSTRAINT uk_time_slot_restaurant_date_start DO NOTHING; + +-- ============================================================ +-- 6. 예약 (reservation_service.p_reservations) — 시나리오 3 검증용 CONFIRMED 데이터 +-- owneruser01이 본인 식당에 예약한 데이터로 생성 (임시) +-- ============================================================ +INSERT INTO reservation_service.p_reservations ( + id, + user_id, + restaurant_id, + time_slot_id, + reserved_date, + guest_count, + status, + cancel_deadline, + modify_deadline, + noshow_deadline, + checked_in_at, + created_at, + created_by, + updated_at, + updated_by, + deleted_at, + deleted_by +) VALUES +( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', -- 시나리오 3 전용 (CONFIRMED 유지) + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', -- owneruser01 + '2589a05e-db8f-4e62-bb3d-22ce85b750a3', -- 테스트 레스토랑 + (SELECT time_slot_id FROM timeslot_service.p_time_slot WHERE target_date = '2026-06-14' AND start_time = '18:00' LIMIT 1), + '2026-06-14', + 2, + 'CONFIRMED', + '2026-06-12', + '2026-06-12', + '2026-06-14 18:30:00', + NULL, + NOW(), + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', + NOW(), + NULL, + NULL, + NULL +), +( + 'b2c3d4e5-f6a7-8901-bcde-f01234567891', -- 시나리오 2 전용 (체크인 테스트용) + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', + '2589a05e-db8f-4e62-bb3d-22ce85b750a3', + (SELECT time_slot_id FROM timeslot_service.p_time_slot WHERE target_date = '2026-06-14' AND start_time = '19:00' LIMIT 1), + '2026-06-14', + 4, + 'CONFIRMED', + '2026-06-12', + '2026-06-12', + '2026-06-14 19:30:00', + NULL, + NOW(), + 'ffa8b6c9-cc64-41e8-a1b0-2b0622489b78', + NOW(), + NULL, + NULL, + NULL +) +ON CONFLICT (id) DO NOTHING; + +-- 결과 확인 +SELECT 'p_users' AS "테이블", COUNT(*) AS "행 수" FROM user_service.p_users +UNION ALL +SELECT 'p_restaurant', COUNT(*) FROM restaurant_service.p_restaurant +UNION ALL +SELECT 'p_restaurant_course', COUNT(*) FROM restaurant_service.p_restaurant_course +UNION ALL +SELECT 'p_restaurant_course_menu', COUNT(*) FROM restaurant_service.p_restaurant_course_menu +UNION ALL +SELECT 'p_time_slot', COUNT(*) FROM timeslot_service.p_time_slot +UNION ALL +SELECT 'p_reservations', COUNT(*) FROM reservation_service.p_reservations; diff --git a/http-tests/start-local.sh b/http-tests/start-local.sh new file mode 100755 index 0000000..b05de58 --- /dev/null +++ b/http-tests/start-local.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Miche-Let 로컬 서비스 기동 스크립트 +# 사용법: ./start-local.sh [서비스명] +# 예시: ./start-local.sh user-service +# ./start-local.sh all (모든 서비스 순서대로 기동) + +set -e + +# 스크립트 위치 기준으로 miche-let 루트 디렉터리 산출 (http-tests/../.. = miche-let/) +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +# infra .env 로드 (값에 공백이 포함될 수 있으므로 set -a 방식 사용) +ENV_FILE="$ROOT/infra/.env" +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +run_service() { + local svc=$1 + local profile=${2:-local} + local extra_args=${3:-} + + echo "▶ Starting $svc (profile=$profile)..." + cd "$ROOT/$svc" + chmod +x gradlew + + if [ -n "$extra_args" ]; then + ./gradlew bootRun --args="--spring.profiles.active=$profile $extra_args" & + else + ./gradlew bootRun --args="--spring.profiles.active=$profile" & + fi + cd "$ROOT" +} + +case "${1:-}" in + user-service) + run_service user-service local + ;; + restaurant-service) + run_service restaurant-service local + ;; + timeslot-service) + run_service timeslot-service + ;; + waiting-service) + run_service waiting-service + ;; + reservation-service) + run_service reservation-service local + ;; + api-gateway) + run_service api-gateway local + ;; + all) + echo "=== 전체 서비스 기동 (Eureka + Docker 인프라가 이미 실행 중이어야 합니다) ===" + run_service user-service local + sleep 5 + run_service restaurant-service local + run_service timeslot-service + run_service waiting-service + sleep 5 + run_service reservation-service local + sleep 5 + run_service api-gateway local + echo "=== 모든 서비스 기동 완료. Eureka 확인: http://localhost:8761 ===" + wait + ;; + *) + echo "사용법: $0 [user-service|restaurant-service|timeslot-service|waiting-service|reservation-service|api-gateway|all]" + echo "" + echo "각 터미널에서 개별 실행:" + echo " source infra/.env && cd user-service && ./gradlew bootRun --args='--spring.profiles.active=local'" + echo " source infra/.env && cd restaurant-service && ./gradlew bootRun --args='--spring.profiles.active=local'" + echo " source infra/.env && cd timeslot-service && ./gradlew bootRun" + echo " source infra/.env && cd waiting-service && ./gradlew bootRun" + echo " source infra/.env && cd reservation-service && ./gradlew bootRun --args='--spring.profiles.active=local'" + echo " source infra/.env && cd api-gateway && ./gradlew bootRun --args='--spring.profiles.active=local'" + ;; +esac diff --git a/src/main/java/com/michelet/reservation/infrastructure/client/TimeSlotClient.java b/src/main/java/com/michelet/reservation/infrastructure/client/TimeSlotClient.java index 11149e6..3975d94 100644 --- a/src/main/java/com/michelet/reservation/infrastructure/client/TimeSlotClient.java +++ b/src/main/java/com/michelet/reservation/infrastructure/client/TimeSlotClient.java @@ -8,7 +8,9 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -@FeignClient(name = "timeslot-service", url = "${feign.timeslot-service.url}") +@FeignClient( + name = "timeslot-service" +) public interface TimeSlotClient { /** diff --git a/src/main/java/com/michelet/reservation/infrastructure/client/WaitingClient.java b/src/main/java/com/michelet/reservation/infrastructure/client/WaitingClient.java index d09e9a6..387aa02 100644 --- a/src/main/java/com/michelet/reservation/infrastructure/client/WaitingClient.java +++ b/src/main/java/com/michelet/reservation/infrastructure/client/WaitingClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; -@FeignClient(name = "waiting-service", url = "${feign.waiting-service.url}") +@FeignClient(name = "waiting-service") public interface WaitingClient { @GetMapping("/internal/waitings/verify-token") diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index a821043..119a773 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,12 +1,20 @@ spring: kafka: bootstrap-servers: localhost:9092 + jpa: + hibernate: + ddl-auto: create + +internal: + secret: ${INTERNAL_AUTH_SECRET} + auth: + secret: ${INTERNAL_AUTH_SECRET} waiting: - stub: true + stub: false timeslot: - stub: true + stub: false kafka: stub: true diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9c46b15..8215bb6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -10,11 +10,17 @@ spring: jpa: hibernate: - ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:update} + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:validate} properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect default_schema: reservation_service + format_sql: true + jdbc.time_zone: Asia/Seoul + order_inserts: true + order_updates: true + jdbc: + batch_size: 30 show-sql: false open-in-view: false @@ -34,10 +40,11 @@ spring: cloud: discovery: enabled: true + eureka: client: service-url: - defaultZone: http://${EUREKA_HOST:localhost}:${EUREKA_PORT:19090}/eureka/ + defaultZone: http://${EUREKA_HOST:localhost}:${EUREKA_PORT:8761}/eureka/ internal: auth: