diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a90b46f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.gradle
+build/
+out/
+.env
+.idea
+*.log
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..5f34690
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,220 @@
+name: Deploy Reservation Service
+
+on:
+ push:
+ branches: [ "main", "dev" ]
+ workflow_dispatch:
+
+env:
+ GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }}
+ GCP_ZONE: ${{ vars.GCP_ZONE }}
+ AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }}
+ VM_NAME: reservation-service
+ VM_USER: zsx1397
+ WORK_DIR: /home/zsx1397/app
+ HEALTH_RETRIES: 30
+ HEALTH_INTERVAL: 10
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ permissions:
+ contents: read
+ id-token: write
+ outputs:
+ image_tag: ${{ steps.meta.outputs.tag }}
+ registry_host: ${{ steps.meta.outputs.registry_host }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Authenticate to GCP
+ id: auth
+ uses: google-github-actions/auth@v2
+ with:
+ token_format: access_token
+ workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
+ service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
+ project_id: ${{ env.GCP_PROJECT }}
+
+ - name: Set up gcloud
+ uses: google-github-actions/setup-gcloud@v2
+
+ - name: Resolve image metadata
+ id: meta
+ run: |
+ echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
+ echo "registry_host=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Artifact Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ steps.meta.outputs.registry_host }}
+ username: oauth2accesstoken
+ password: ${{ steps.auth.outputs.access_token }}
+
+ - name: Build and push image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ env.AR_IMAGE_PATH }}:${{ steps.meta.outputs.tag }}
+ ${{ env.AR_IMAGE_PATH }}:latest
+ secrets: |
+ GPR_USER=${{ secrets.GPR_USER }}
+ GPR_TOKEN=${{ secrets.GPR_TOKEN }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ deploy:
+ needs: build-and-push
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Authenticate to GCP
+ id: auth
+ uses: google-github-actions/auth@v2
+ with:
+ token_format: access_token
+ workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
+ service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
+ project_id: ${{ env.GCP_PROJECT }}
+
+ - name: Set up gcloud
+ uses: google-github-actions/setup-gcloud@v2
+
+ - name: Setup SSH
+ run: |
+ mkdir -p ~/.ssh
+ echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/google_compute_engine
+ chmod 600 ~/.ssh/google_compute_engine
+ ssh-keygen -y -f ~/.ssh/google_compute_engine > ~/.ssh/google_compute_engine.pub
+ ssh-keyscan -H 34.64.247.238 >> ~/.ssh/known_hosts
+
+ - name: Check VM status
+ id: vm-status
+ run: |
+ STATUS=$(gcloud compute instances describe "$VM_NAME" \
+ --zone="$GCP_ZONE" \
+ --project="$GCP_PROJECT" \
+ --format="value(status)" 2>/dev/null || echo "NOT_FOUND")
+ echo "status=$STATUS" >> "$GITHUB_OUTPUT"
+
+ - name: Copy Config files
+ if: steps.vm-status.outputs.status == 'RUNNING'
+ run: |
+ gcloud compute scp deploy/docker-compose.prod.yaml \
+ "$VM_USER@$VM_NAME:/tmp/docker-compose.prod.yaml" \
+ --zone="$GCP_ZONE" --project="$GCP_PROJECT" --tunnel-through-iap --ssh-key-file=~/.ssh/google_compute_engine --scp-flag=-O --quiet
+
+ gcloud compute scp deploy/promtail-config.yml \
+ "$VM_USER@$VM_NAME:/tmp/promtail-config.yml" \
+ --zone="$GCP_ZONE" --project="$GCP_PROJECT" --tunnel-through-iap --ssh-key-file=~/.ssh/google_compute_engine --scp-flag=-O --quiet
+
+ - name: Copy Kafka truststore
+ if: steps.vm-status.outputs.status == 'RUNNING'
+ env:
+ KAFKA_TRUSTSTORE_JKS_BASE64: ${{ secrets.KAFKA_TRUSTSTORE_JKS_BASE64 }}
+ run: |
+ if [ -z "$KAFKA_TRUSTSTORE_JKS_BASE64" ]; then
+ exit 0
+ fi
+
+ printf '%s' "$KAFKA_TRUSTSTORE_JKS_BASE64" | base64 -d > kafka.server.truststore.jks
+
+ gcloud compute scp kafka.server.truststore.jks \
+ "$VM_USER@$VM_NAME:~/kafka.server.truststore.jks" \
+ --zone="$GCP_ZONE" \
+ --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --ssh-key-file=~/.ssh/google_compute_engine \
+ --scp-flag=-O \
+ --quiet
+
+ gcloud compute ssh "$VM_USER@$VM_NAME" \
+ --zone="$GCP_ZONE" \
+ --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --ssh-key-file=~/.ssh/google_compute_engine \
+ --quiet \
+ --command="
+ sudo chmod 755 /home/$VM_USER
+ sudo mkdir -p '$WORK_DIR/ssl'
+ sudo mkdir -p '$WORK_DIR/logs'
+ sudo chmod 755 '$WORK_DIR'
+
+ if [ -f ~/kafka.server.truststore.jks ]; then
+ sudo mv ~/kafka.server.truststore.jks '$WORK_DIR/ssl/kafka.server.truststore.jks'
+ fi
+
+ sudo chown -R 1000:1000 '$WORK_DIR/ssl'
+ sudo chown -R 1000:1000 '$WORK_DIR/logs'
+ sudo chmod 755 '$WORK_DIR/ssl'
+ sudo chmod 777 '$WORK_DIR/logs'
+ sudo chmod 644 '$WORK_DIR/ssl/kafka.server.truststore.jks'
+ "
+
+ - name: Deploy and verify
+ if: steps.vm-status.outputs.status == 'RUNNING'
+ env:
+ IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
+ AR_TOKEN: ${{ steps.auth.outputs.access_token }}
+ REGISTRY_HOST: ${{ needs.build-and-push.outputs.registry_host }}
+ run: |
+ gcloud compute ssh "$VM_USER@$VM_NAME" \
+ --zone="$GCP_ZONE" \
+ --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --ssh-key-file=~/.ssh/google_compute_engine \
+ --quiet \
+ --command="
+ set -e
+
+ echo '$AR_TOKEN' | docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST
+
+ mkdir -p '$WORK_DIR'
+ [ -f /tmp/docker-compose.prod.yaml ] && mv /tmp/docker-compose.prod.yaml '$WORK_DIR/docker-compose.prod.yaml'
+ [ -f /tmp/promtail-config.yml ] && mv /tmp/promtail-config.yml '$WORK_DIR/promtail-config.yml'
+ cd '$WORK_DIR'
+
+ find . -mindepth 1 -maxdepth 1 ! -name '.env' ! -name 'ssl' ! -name 'logs' ! -name 'docker-compose.prod.yaml' ! -name 'promtail-config.yml' -exec rm -rf {} +
+
+ test -f .env || { exit 1; }
+
+ docker network inspect pgsg-network >/dev/null 2>&1 || docker network create pgsg-network
+
+ docker image prune -af || true
+
+ COMPOSE_CMD='docker compose'
+ if ! \$COMPOSE_CMD version >/dev/null 2>&1; then
+ COMPOSE_CMD='docker-compose'
+ fi
+
+ IMAGE_TAG='$IMAGE_TAG' AR_IMAGE_PATH='$AR_IMAGE_PATH' \$COMPOSE_CMD -f docker-compose.prod.yaml pull
+ IMAGE_TAG='$IMAGE_TAG' AR_IMAGE_PATH='$AR_IMAGE_PATH' \$COMPOSE_CMD -f docker-compose.prod.yaml up -d
+
+ for i in \$(seq 1 $HEALTH_RETRIES); do
+ if curl -sf http://localhost:8085/actuator/health | grep -q '\"status\":\"UP\"'; then
+ docker ps
+ exit 0
+ fi
+ sleep $HEALTH_INTERVAL
+ done
+
+ docker ps
+ docker logs reservation-service --tail=200
+ exit 1
+ "
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..830f3ec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,56 @@
+HELP.md
+.gradle
+**/build/
+**/out/
+**/bin/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+.env
+.env.*
+
+# QueryDSL
+src/main/generated/
+**/generated/
+
+# Artifacts
+*.jar
+*.war
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Security & Certificates ###
+*.jks
+kafka.server.truststore.jks
+
+# Gradle credentials
+gradle.properties
diff --git a/.gradle/9.0.0/checksums/checksums.lock b/.gradle/9.0.0/checksums/checksums.lock
deleted file mode 100644
index fccd03d..0000000
Binary files a/.gradle/9.0.0/checksums/checksums.lock and /dev/null differ
diff --git a/.gradle/9.0.0/checksums/md5-checksums.bin b/.gradle/9.0.0/checksums/md5-checksums.bin
deleted file mode 100644
index 3ec758e..0000000
Binary files a/.gradle/9.0.0/checksums/md5-checksums.bin and /dev/null differ
diff --git a/.gradle/9.0.0/checksums/sha1-checksums.bin b/.gradle/9.0.0/checksums/sha1-checksums.bin
deleted file mode 100644
index c879418..0000000
Binary files a/.gradle/9.0.0/checksums/sha1-checksums.bin and /dev/null differ
diff --git a/.gradle/9.0.0/executionHistory/executionHistory.bin b/.gradle/9.0.0/executionHistory/executionHistory.bin
deleted file mode 100644
index 1396b9b..0000000
Binary files a/.gradle/9.0.0/executionHistory/executionHistory.bin and /dev/null differ
diff --git a/.gradle/9.0.0/executionHistory/executionHistory.lock b/.gradle/9.0.0/executionHistory/executionHistory.lock
deleted file mode 100644
index c43cd63..0000000
Binary files a/.gradle/9.0.0/executionHistory/executionHistory.lock and /dev/null differ
diff --git a/.gradle/9.0.0/fileChanges/last-build.bin b/.gradle/9.0.0/fileChanges/last-build.bin
deleted file mode 100644
index f76dd23..0000000
Binary files a/.gradle/9.0.0/fileChanges/last-build.bin and /dev/null differ
diff --git a/.gradle/9.0.0/fileHashes/fileHashes.bin b/.gradle/9.0.0/fileHashes/fileHashes.bin
deleted file mode 100644
index 1404cd7..0000000
Binary files a/.gradle/9.0.0/fileHashes/fileHashes.bin and /dev/null differ
diff --git a/.gradle/9.0.0/fileHashes/fileHashes.lock b/.gradle/9.0.0/fileHashes/fileHashes.lock
deleted file mode 100644
index 2af0afd..0000000
Binary files a/.gradle/9.0.0/fileHashes/fileHashes.lock and /dev/null differ
diff --git a/.gradle/9.0.0/gc.properties b/.gradle/9.0.0/gc.properties
deleted file mode 100644
index e69de29..0000000
diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock
deleted file mode 100644
index 24074fc..0000000
Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and /dev/null differ
diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties
deleted file mode 100644
index 23847c6..0000000
--- a/.gradle/buildOutputCleanup/cache.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-#Fri Apr 24 16:13:26 KST 2026
-gradle.version=9.0.0
diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin
deleted file mode 100644
index 8f2b07b..0000000
Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and /dev/null differ
diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe
deleted file mode 100644
index c6b7878..0000000
Binary files a/.gradle/file-system.probe and /dev/null differ
diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties
deleted file mode 100644
index e69de29..0000000
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 9879198..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,10 +0,0 @@
-# 디폴트 무시된 파일
-/shelf/
-/workspace.xml
-# 쿼리 파일을 포함한 무시된 디폴트 폴더
-/queries/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
-# 에디터 기반 HTTP 클라이언트 요청
-/httpRequests/
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b64384e..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index ba50364..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index bad0966..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/reservation-service.iml b/.idea/reservation-service.iml
deleted file mode 100644
index d6ebd48..0000000
--- a/.idea/reservation-service.iml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1dd..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..283f5ba
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,50 @@
+# syntax=docker/dockerfile:1.7
+
+# 빌드 단계
+FROM eclipse-temurin:21-jdk-alpine AS build
+WORKDIR /app
+
+RUN apk add --no-cache dos2unix
+
+# 모든 소스 코드 복사
+COPY . .
+
+# 복사된 파일 중 gradlew만 골라서 줄바꿈 변환 및 실행 권한 부여
+# (COPY . . 이후에 수행해야 덮어쓰기 문제를 방지할 수 있습니다)
+RUN dos2unix gradlew && chmod +x gradlew
+
+# 빌드 실행
+ARG GPR_USER
+ARG GPR_TOKEN
+RUN --mount=type=secret,id=GPR_USER,required=false \
+ --mount=type=secret,id=GPR_TOKEN,required=false \
+ GPR_USER_VALUE="$(cat /run/secrets/GPR_USER 2>/dev/null || echo "${GPR_USER}")" && \
+ GPR_TOKEN_VALUE="$(cat /run/secrets/GPR_TOKEN 2>/dev/null || echo "${GPR_TOKEN}")" && \
+ ./gradlew clean bootJar -x test -Pgpr.user="${GPR_USER_VALUE}" -Pgpr.key="${GPR_TOKEN_VALUE}"
+
+# 실행 단계
+FROM eclipse-temurin:21-jre-alpine
+WORKDIR /app
+
+# 실행 전용 사용자 생성
+RUN addgroup -S appuser && adduser -S appuser -G appuser
+
+# 빌드 결과물 복사
+COPY --from=build /app/build/libs/*-SNAPSHOT.jar app.jar
+
+# SSL 인증서 복사 (물리 경로 확보)
+RUN mkdir -p /app/ssl
+COPY --from=build /app/src/main/resources/ssl/ /app/ssl/
+
+# 권한 설정
+RUN chown -R appuser:appuser /app
+
+# 환경 변수 설정 (Kafka SSL 비밀번호 강제 주입)
+ENV SPRING_PROFILES_ACTIVE=dev,kafka
+ENV SPRING_KAFKA_SSL_TRUST_STORE_LOCATION=file:/app/ssl/kafka.server.truststore.jks
+ENV SPRING_KAFKA_SSL_TRUST_STORE_PASSWORD=_aA123456
+
+EXPOSE 8085
+USER appuser
+
+ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]
diff --git a/README.md b/README.md
index 0e721a8..23d035d 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,262 @@
-# reservation-service
\ No newline at end of file
+# reservation-service
+
+예약 생명주기를 담당하는 Spring Boot 기반 마이크로서비스입니다.대량의 동시 예약 신청 트래픽을 제어하기 위해 Redisson 분산 락 아키텍처를 도입했으며,예약 성공 및 상태 변경 시 아웃박스(Outbox) 패턴을 통해 이벤트를 발행합니다.
+
+## 주요 기능
+
+- 상품 생성 Kafka 이벤트 수신 후 예약 자동 생성
+- 예약 목록/단건 상세 조회 REST API 제공
+- 분산 락(Redisson) 기반 안전한 동시 예약 신청 제어
+- 구매자/판매자 상호 예약 확정 및 취소 처리
+- 트랜잭션 보장(Outbox 패턴)을 통한 예약 완료/취소/실패 이벤트 발행
+- Promtail 호환 설정을 통한 분산 로그 수집
+
+## 기술 스택
+
+- Java 21
+- Spring Boot 3.5.13
+- Spring Cloud 2025.0.2
+- Spring Data JPA
+- Querydsl 6.8
+- Spring Data Redis & Redisson
+- Spring Kafka
+- Eureka Client
+- Spring Security & JWT
+- PostgreSQL
+- Docker, Docker Compose
+- Grafana Alloy (Promtail config format)
+
+## 프로젝트 구조
+
+```text
+src/main/java/org/pgsg/reservation
+├── application # 유스케이스, 서비스, 포트, 커맨드/결과 DTO
+├── domain # 예약 도메인 모델, 값 객체, 도메인 예외
+├── infrastructure # JPA 레포지토리 구현체, Kafka, Redis 인프라 어댑터
+└── presentation # REST 컨트롤러, API 요청/응답 DTO, 분산 락 제어 패사드
+```
+
+## 사전 준비
+
+- JDK 21
+- Docker, Docker Compose
+- GitHub Package Registry 접근 권한
+- 실행 환경에 맞는 Config Server, Eureka, Kafka, DB 설정
+
+`org.pgsg:common` 패키지를 GitHub Package Registry에서 내려받기 때문에 Gradle 인증 정보가 필요합니다.
+
+```properties
+# ~/.gradle/gradle.properties
+gpr.user=GITHUB_USERNAME
+gpr.token=GITHUB_TOKEN
+```
+
+또는 환경변수로 지정할 수 있습니다.
+
+```bash
+export GPR_USER=GITHUB_USERNAME
+export GPR_TOKEN=GITHUB_TOKEN
+```
+
+## 설정
+
+애플리케이션은 기본적으로 `.env`, Config Server, Eureka를 사용합니다.
+
+```yaml
+spring:
+ application:
+ name: reservation-service
+ profiles:
+ active: kafka, topics, dev
+ config:
+ import:
+ - "optional:file:.env[.properties]"
+ - "optional:configserver:"
+```
+
+로컬 또는 배포 환경에서는 다음 값이 필요합니다.
+
+| 변수 | 설명 |
+| --- | --- |
+| `SERVER_IP` | Eureka 서버 URL 목록 |
+| `SERVER_PORT` | 외부 포트 바인딩용 변수 |
+| `DB_NAME` | 연결 및 생성 대상 PostgreSQL 데이터베이스 이름 |
+| `DB_USERNAME` | PostgreSQL 데이터베이스 사용자명 |
+| `DB_PASSWORD` | PostgreSQL 데이터베이스 접근 비밀번호 |
+| `REDIS_HOST` | 분산 락 및 캐시용 Redis 컨테이너 접속 호스트명 |
+| `MANAGEMENT_ZIPKIN_TRACING_ENDPOINT` | 트레이싱 수집용 외부 Zipkin API 엔드포인트 URL |
+| `KAFKA_SSL_PATH` | 컨테이너 내부에 마운트되는 Kafka SSL truststore 파일 경로 |
+| `AR_IMAGE_PATH` | Google Artifact Registry 등 도커 이미지 배포 저장소 경로 |
+| `IMAGE_TAG` | 도커 이미지 빌드 및 배포용 태그 버전 |
+| `GPR_USER` | GitHub Package Registry 사용자명 |
+| `GPR_TOKEN` | GitHub Package Registry 토큰 |
+
+Kafka topic 설정은 Config Server 또는 활성 프로필 설정에서 제공되어야 합니다.
+
+| 설정 키 | 설명 |
+| --- | --- |
+| `prod-reservation-completed` | 예약 완료 이벤트 발행 topic |
+| `prod-reservation-cancelled` | 예약 취소 이벤트 발행 topic |
+| `prod-reservation-tradefail` | 거래 실패로 인한 예약 실패 처리 수신 topic |
+| `prod-product-created` | 상품 생성 이벤트 수신 topic |
+| `prod-product-failed` | 상품 생성 실패 이벤트 수신 topic |
+
+## Docker 실행
+
+로컬 Docker Compose는 `trade-service`와 로그 수집용 Alloy 컨테이너를 함께 실행합니다.
+
+```bash
+docker compose up --build
+```
+
+기본 포트 매핑은 다음과 같습니다.
+
+| 대상 | 포트 |
+| --- | --- |
+| Host | `19020` |
+| Container | `8085` |
+
+Compose 실행 전 외부 네트워크가 필요합니다.
+
+```bash
+docker network create pgsg-network
+```
+
+Docker 빌드 시 `~/.gradle/gradle.properties`가 BuildKit secret으로 전달됩니다.
+
+## API
+
+### 예약 생성
+
+```http
+POST /api/v1/reservations
+```
+
+### 예약 목록 조회
+
+```http
+GET /api/v1/reservations?page=0&size=10
+```
+- page: 0 이상의 페이지 번호
+- size: 1 이상의 페이지 크기 (기본값: 10)
+- sellerName: 판매자 이름 필터 (선택)
+- buyerName: 구매자 이름 필터 (선택)
+- productName: 상품명 필터 (선택)
+- status: 예약 상태 필터 (선택)
+- productId: 상품 ID 필터 (선택)
+
+### 예약 상세 목록 조회
+
+```http
+GET /api/v1/reservations/{reservationId}
+```
+
+### 예약 신청
+
+```http
+PATCH /api/v1/reservations/{reservationId}
+```
+
+### 구매자 사유 취소
+
+```http
+PATCH /api/v1/reservations/{reservationId}/cancel/buyer
+```
+
+### 결제 완료
+
+```http
+PATCH /api/v1/reservations/{reservationId}/paymentconfirm
+```
+
+### 판매자 사유 취소
+
+```http
+PATCH /api/v1/reservations/{reservationId}/cancel/seller
+```
+
+### 예약 만료 (관리자)
+
+```http
+PATCH /api/v1/reservations/{reservationId}/expire
+```
+
+### 예약 완료
+
+```http
+PATCH /api/v1/reservations/{reservationId}/complete
+```
+
+### 거래 완료
+```http
+PATCH /api/v1/reservations/{reservationId}/tradeconfirm
+```
+
+예약 및 거래 상태 관리 로직
+- 인증 사용자 기반 상태 저장: Bearer Token을 이용해 요청한 현재 사용자의 권한을 검증하고,해당 예약건에 대한 구매자 또는 판매자의 개별 상태를 저장합니다.
+
+최종 예 완료 및 이벤트 발행 조건 (COMPLETED):
+- 구매자의 결제 완료(paymentconfirm)와 판매자의 채팅 수락(complete) 두 개의 상호 확정이 모두 이루어져야 합니다.
+- 구매자와 판매자가 모두 예약 완료 처리를 마치는 시점에 최종적으로 거래 상태가 COMPLETED로 전환되며, 외부 서비스 전파를 위한 예약 완료 이벤트 발행이 요청됩니다.
+
+## 이벤트 흐름
+
+### 수신 이벤트
+
+`prod-product-created` topic의 상품 타임 이벤트를 수신해 예약를 생성합니다.
+
+```json
+{
+ "productId": "00000000-0000-0000-0000-000000000000",
+ "name": "상품명",
+ "price": 10000,
+ "endTime": [ 2026,5,21,12,24,41 ],
+ "sellerId": "00000000-0000-0000-0000-000000000000",
+ "sellerName": "판매자"
+}
+```
+
+`prod-trade-completed` topic의 거래 완료 이벤트를 수신해 예약은 완료합니다.
+```json
+{
+ "tradeId": "00000000-0000-0000-0000-000000000000",
+ "reservationId": "00000000-0000-0000-0000-000000000000",
+ "productId": "00000000-0000-0000-0000-000000000000"
+}
+```
+
+### 발행 이벤트
+
+서비스는 common 모듈의 Outbox 이벤트를 통해 다음 이벤트 등록을 요청합니다.
+
+- 예약 완료: prod-reservation-completed
+- 예약 취소: prod-reservation-cancelled
+- 예약 실패: prod-reservation-tradefail
+
+## 로그 수집
+
+`promtail-config.yml`과 `deploy/promtail-config.yml`은 `/logs/*.log` 파일을 수집해 Loki 엔드포인트로 전송합니다. 로컬 Compose와 운영 Compose 모두 Grafana Alloy를 Promtail config format으로 실행합니다.
+
+## 배포
+
+운영 Compose 파일은 `deploy/docker-compose.prod.yaml`에 있습니다.
+
+```bash
+docker compose -f deploy/docker-compose.prod.yaml up -d
+```
+
+운영 환경에서는 다음 경로를 사용합니다.
+
+| 경로 | 용도 |
+| --- | --- |
+| `/opt/reservation-service/ssl` | Kafka SSL 인증서 |
+| `/opt/reservation-service/logs` | 애플리케이션 로그 |
+| `/opt/reservation-service/promtail-config.yml` | 로그 수집 설정 |
+
+## 테스트
+
+```bash
+./gradlew test
+```
+
+테스트는 도메인 모델, 애플리케이션 서비스, 영속성 어댑터, Kafka Consumer, 컨트롤러, 아키텍처 규칙을 검증합니다.
diff --git a/build.gradle b/build.gradle
index 6c1daa1..87497f9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,26 +2,117 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.5.13'
id 'io.spring.dependency-management' version '1.1.7'
+ id 'org.jetbrains.kotlin.jvm' version '1.9.22'
}
+
group = 'org.pgsg'
version = '0.0.1-SNAPSHOT'
java {
- sourceCompatibility = '17'
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
}
repositories {
mavenCentral()
+ maven {
+ url = uri("https://maven.pkg.github.com/89-49/common")
+ credentials {
+ username = project.findProperty("gpr.user") ?: System.getenv("GPR_USER")
+ password = project.findProperty("gpr.key") ?: System.getenv("GPR_TOKEN")
+ }
+ }
+}
+
+ext {
+ set('springCloudVersion', "2025.0.2")
+}
+
+dependencyManagement {
+ imports {
+ mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
+ }
}
dependencies {
- implementation project(':common')
+ // [Lombok]
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ // [Spring Cloud]
+ implementation 'org.springframework.cloud:spring-cloud-starter-config'
+ implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
+
+ // [Actuator] - 상태 확인
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+
+ // [Dotenv] - .env 파일 인식용
+ implementation 'io.github.cdimascio:java-dotenv:5.1.1'
+
+ // [Kafka]
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // [Querydsl]
+ implementation 'io.github.openfeign.querydsl:querydsl-core:6.8'
+ implementation 'io.github.openfeign.querydsl:querydsl-jpa:6.8'
+ annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.8:jpa'
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+
+ // [JWT]
+ implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
+ // [Common Module]
+ implementation 'org.pgsg:common:0.3.2-SNAPSHOT'
+
+ // [Spring Starters]
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
- compileOnly 'org.projectlombok:lombok'
- annotationProcessor 'org.projectlombok:lombok'
- runtimeOnly 'com.mysql:mysql-connector-j'
+ // [swagger]
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5'
+ implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.22'
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
+
+ // [Database]
+ runtimeOnly 'org.postgresql:postgresql'
+
+ // [Redis]
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.redisson:redisson-spring-boot-starter:3.51.0'
+
+ // [Observability]
+ implementation 'io.opentelemetry:opentelemetry-exporter-zipkin'
+
+ // [Logback]
+ implementation 'io.github.openfeign:feign-micrometer'
+}
+
+def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile
+
+sourceSets {
+ main.java.srcDirs += [querydslDir]
+}
+
+tasks.withType(JavaCompile).configureEach {
+ options.getGeneratedSourceOutputDirectory().set(querydslDir)
+ options.annotationProcessorPath = configurations.annotationProcessor
+}
+
+bootJar { enabled = true }
+jar { enabled = false }
+
+clean {
+ delete querydslDir
}
\ No newline at end of file
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
deleted file mode 100644
index ba0c211..0000000
--- a/build/reports/problems/problems-report.html
+++ /dev/null
@@ -1,663 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Gradle Configuration Cache
-
-
-
-
-
-
- Loading...
-
-
-
-
-
-
-
diff --git a/deploy/docker-compose.prod.yaml b/deploy/docker-compose.prod.yaml
new file mode 100644
index 0000000..093ca46
--- /dev/null
+++ b/deploy/docker-compose.prod.yaml
@@ -0,0 +1,76 @@
+services:
+ reservation-service:
+ container_name: reservation-service
+ image: ${AR_IMAGE_PATH}:${IMAGE_TAG:-latest}
+ restart: always
+ env_file:
+ - .env
+ environment:
+ EUREKA_INSTANCE_PREFER_IP_ADDRESS: "true"
+ EUREKA_INSTANCE_IP_ADDRESS: ${SERVER_IP}
+ EUREKA_INSTANCE_HOSTNAME: ${SERVER_IP}
+ EUREKA_INSTANCE_INSTANCE_ID: ${SERVER_IP}:reservation-service:${SERVER_PORT:-8085}
+ EUREKA_INSTANCE_NON_SECURE_PORT: ${SERVER_PORT:-8085}
+ DB_URL: jdbc:postgresql://reservation-db:5432/${DB_NAME:-reservation_db}
+ REDIS_HOST: reservation-redis
+ MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "http://34.47.69.9:9411/api/v2/spans"
+ KAFKA_SSL_PATH: /app/ssl/kafka.server.truststore.jks
+ volumes:
+ - ./ssl:/app/ssl:ro
+ - ./logs:/app/logs
+ ports:
+ - "${SERVER_PORT:-8085}:${SERVER_PORT:-8085}"
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ networks:
+ - pgsg-network
+
+ promtail:
+ image: grafana/promtail:2.9.1
+ container_name: promtail
+ volumes:
+ - ./promtail-config.yml:/etc/promtail/config.yml
+ - ./logs:/logs
+ command: -config.file=/etc/promtail/config.yml -config.expand-env=true
+ env_file:
+ - .env
+ networks:
+ - pgsg-network
+
+ db:
+ image: postgres:17
+ container_name: reservation-db
+ environment:
+ POSTGRES_DB: ${DB_NAME:-reservation_db}
+ POSTGRES_USER: ${DB_USERNAME:-postgres}
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ volumes:
+ - postgres-reservation-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-reservation_db}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - pgsg-network
+
+ redis:
+ image: redis:7.2
+ container_name: reservation-redis
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - pgsg-network
+
+networks:
+ pgsg-network:
+ external: true
+
+volumes:
+ postgres-reservation-data:
diff --git a/deploy/promtail-config.yml b/deploy/promtail-config.yml
new file mode 100644
index 0000000..655f178
--- /dev/null
+++ b/deploy/promtail-config.yml
@@ -0,0 +1,18 @@
+server:
+ http_listen_port: 9080
+ grpc_listen_port: 0
+
+positions:
+ filename: /tmp/positions.yaml
+
+clients:
+ - url: http://34.47.69.9:3100/loki/api/v1/push
+
+scrape_configs:
+ - job_name: reservation-service
+ static_configs:
+ - targets:
+ - localhost
+ labels:
+ job: reservation-service
+ __path__: /home/zsx1397/app/logs/*.log
\ No newline at end of file
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
new file mode 100644
index 0000000..3864799
--- /dev/null
+++ b/docker-compose.local.yml
@@ -0,0 +1,70 @@
+services:
+ reservation-service:
+ container_name: reservation-service
+ build:
+ context: .
+ args:
+ - GPR_USER=${GPR_USER}
+ - GPR_TOKEN=${GPR_TOKEN}
+ env_file:
+ - .env.local
+ environment:
+ - "SERVER_PORT=${SERVER_PORT:-8085}"
+ - "SPRING_CLOUD_CONFIG_ENABLED=false"
+ - "SPRING_CLOUD_CONFIG_DISCOVERY_ENABLED=false"
+ - "EUREKA_CLIENT_ENABLED=false"
+ - "EUREKA_CLIENT_REGISTER_WITH_EUREKA=false"
+ - "EUREKA_CLIENT_FETCH_REGISTRY=false"
+ - "DB_URL=jdbc:postgresql://reservation-db:5432/${DB_NAME:-reservation_db}"
+ - "REDIS_HOST=reservation-redis"
+ - "KAFKA_SSL_PATH=/app/ssl/kafka.server.truststore.jks"
+ volumes:
+ - ./src/main/resources/ssl:/app/ssl:ro
+ ports:
+ - "${SERVER_PORT:-8085}:${SERVER_PORT:-8085}"
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ networks:
+ - pgsg-network
+
+ db:
+ image: postgres:17
+ container_name: reservation-db
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_DB: ${DB_NAME:-reservation_db}
+ POSTGRES_USER: ${DB_USERNAME:-postgres}
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
+ volumes:
+ - postgres-reservation-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - pgsg-network
+
+ redis:
+ image: redis:7.2
+ container_name: reservation-redis
+ ports:
+ - "6379:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - pgsg-network
+
+networks:
+ pgsg-network:
+ name: pgsg-network
+
+volumes:
+ postgres-reservation-data:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..de5a2a8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,77 @@
+version: '3.8'
+
+services:
+ reservation-service:
+ container_name: reservation-service
+ build:
+ context: .
+ args:
+ - GPR_USER=${GPR_USER}
+ - GPR_TOKEN=${GPR_TOKEN}
+ environment:
+ - "SERVER_PORT=${SERVER_PORT}"
+ - "EUREKA_INSTANCE_PREFER_IP_ADDRESS=true"
+ - "EUREKA_INSTANCE_IP_ADDRESS=${SERVER_IP:-localhost}"
+ - "EUREKA_INSTANCE_HOSTNAME=${SERVER_IP:-localhost}"
+ - "EUREKA_INSTANCE_INSTANCE_ID=${SERVER_IP:-localhost}:reservation-service:${SERVER_PORT}"
+ - "EUREKA_INSTANCE_NON_SECURE_PORT=${SERVER_PORT}"
+ - "DB_URL=jdbc:postgresql://reservation-db:5432/${DB_NAME}"
+ - "REDIS_HOST=reservation-redis"
+ - "EUREKA_SERVER_URL=${EUREKA_SERVER_URL}"
+ - "KAFKA_SSL_PATH=/app/ssl/kafka.server.truststore.jks"
+ env_file:
+ - .env
+ volumes:
+ # 로컬의 resources/ssl 폴더를 컨테이너의 /app/ssl 폴더로 연결
+ - ./src/main/resources/ssl:/app/ssl:ro
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ ports:
+ - "${SERVER_PORT}:${SERVER_PORT}"
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ networks:
+ - pgsg-network
+
+ db:
+ image: postgres:17
+ container_name: reservation-db
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_DB: ${DB_NAME:-reservation_db}
+ POSTGRES_USER: ${DB_USERNAME:-postgres}
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ volumes:
+ - postgres-reservation-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - pgsg-network
+
+ redis:
+ image: redis:7.2
+ container_name: reservation-redis
+ ports:
+ - "6379:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - pgsg-network
+
+networks:
+ pgsg-network:
+ name: pgsg-network
+ external: true
+
+volumes:
+ postgres-reservation-data:
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 2a84e18..07aa335 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
+distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/libs/common b/libs/common
deleted file mode 160000
index 1ee295e..0000000
--- a/libs/common
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 1ee295e6ff9bf25f15426b56603bfcb77f62d66a
diff --git a/qodana.yaml b/qodana.yaml
new file mode 100644
index 0000000..9f4d242
--- /dev/null
+++ b/qodana.yaml
@@ -0,0 +1,49 @@
+#-------------------------------------------------------------------------------#
+# Qodana analysis is configured by qodana.yaml file #
+# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
+#-------------------------------------------------------------------------------#
+
+#################################################################################
+# WARNING: Do not store sensitive information in this file, #
+# as its contents will be included in the Qodana report. #
+#################################################################################
+version: "1.0"
+
+#Specify inspection profile for code analysis
+profile:
+ name: qodana.starter
+
+#Enable inspections
+#include:
+# - name:
+
+#Disable inspections
+#exclude:
+# - name:
+# paths:
+# -
+
+projectJDK: "25" #(Applied in CI/CD pipeline)
+
+#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
+#bootstrap: sh ./prepare-qodana.sh
+
+#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
+#plugins:
+# - id: #(plugin id can be found at https://plugins.jetbrains.com)
+
+# Quality gate. Will fail the CI/CD pipeline if any condition is not met
+# severityThresholds - configures maximum thresholds for different problem severities
+# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
+# Code Coverage is available in Ultimate and Ultimate Plus plans
+#failureConditions:
+# severityThresholds:
+# any: 15
+# critical: 5
+# testCoverageThresholds:
+# fresh: 70
+# total: 50
+
+#Qodana supports other languages, for example, Python, JavaScript, TypeScript, Go, C#, PHP
+#For all supported languages see https://www.jetbrains.com/help/qodana/linters.html
+linter: jetbrains/qodana-jvm-community:2025.3
diff --git a/settings.gradle b/settings.gradle
index c78afd4..192c37c 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,4 +1 @@
-rootProject.name = 'reservation-service'
-
-include ':common'
-project(':common').projectDir = new File(settingsDir, 'libs/common')
\ No newline at end of file
+rootProject.name = 'reservation-root'
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/ReservationServiceApplication.java b/src/main/java/org/pgsg/reservation/ReservationServiceApplication.java
new file mode 100644
index 0000000..dd010c1
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/ReservationServiceApplication.java
@@ -0,0 +1,23 @@
+package org.pgsg.reservation;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.data.web.config.EnableSpringDataWebSupport;
+
+@SpringBootApplication(scanBasePackages = {"org.pgsg.reservation", "org.pgsg.common"})
+@EntityScan(basePackages = {
+ "org.pgsg.reservation",
+ "org.pgsg.common.domain"
+})
+@EnableJpaRepositories(basePackages = {
+ "org.pgsg.reservation",
+ "org.pgsg.common.domain"
+})
+@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
+public class ReservationServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(ReservationServiceApplication.class, args);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/command/ReservationApplyCommand.java b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationApplyCommand.java
new file mode 100644
index 0000000..6b44b2a
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationApplyCommand.java
@@ -0,0 +1,13 @@
+package org.pgsg.reservation.application.dto.command;
+
+import java.util.UUID;
+
+public record ReservationApplyCommand(
+ UUID reservationId,
+ UUID userId,
+ String nickname
+) {
+ public static ReservationApplyCommand of(UUID reservationId, UUID userId, String nickname) {
+ return new ReservationApplyCommand(reservationId, userId, nickname);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/command/ReservationCancelCommand.java b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationCancelCommand.java
new file mode 100644
index 0000000..c24cd4c
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationCancelCommand.java
@@ -0,0 +1,14 @@
+package org.pgsg.reservation.application.dto.command;
+
+import java.util.UUID;
+
+public record ReservationCancelCommand(
+ UUID reservationId,
+ UUID userId,
+ String role,
+ String reason
+) {
+ public static ReservationCancelCommand of(UUID id, UUID userId, String role, String reason) {
+ return new ReservationCancelCommand(id, userId, role, reason);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/command/ReservationConfirmCommand.java b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationConfirmCommand.java
new file mode 100644
index 0000000..0f418da
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationConfirmCommand.java
@@ -0,0 +1,13 @@
+package org.pgsg.reservation.application.dto.command;
+
+import java.util.UUID;
+
+public record ReservationConfirmCommand(
+ UUID reservationId,
+ UUID userId,
+ String role
+){
+ public static ReservationConfirmCommand of(UUID reservationId, UUID userId, String role) {
+ return new ReservationConfirmCommand(reservationId,userId,role);
+ }
+}
diff --git a/src/main/java/org/pgsg/reservation/application/dto/command/ReservationCreateCommand.java b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationCreateCommand.java
new file mode 100644
index 0000000..4cf92a4
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationCreateCommand.java
@@ -0,0 +1,14 @@
+package org.pgsg.reservation.application.dto.command;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record ReservationCreateCommand (
+ UUID productId,
+ UUID sellerId,
+ String sellerName,
+ String productName,
+ Integer price,
+ LocalDateTime endTime
+){
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/command/ReservationExpireCommand.java b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationExpireCommand.java
new file mode 100644
index 0000000..a0f5c92
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/command/ReservationExpireCommand.java
@@ -0,0 +1,24 @@
+package org.pgsg.reservation.application.dto.command;
+
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import org.pgsg.reservation.presentation.dto.request.ReservationAdminCancelRequest;
+
+import java.util.UUID;
+
+public record ReservationExpireCommand(
+ UUID reservationId,
+ UUID userId,
+ String role,
+ ReservationStatus targetStatus,
+ String reason
+) {
+ public static ReservationExpireCommand of(UUID reservationId, UUID userId, String role, ReservationAdminCancelRequest request) {
+ return new ReservationExpireCommand(
+ reservationId,
+ userId,
+ role,
+ request.targetStatus(),
+ request.reason()
+ );
+ }
+}
diff --git a/src/main/java/org/pgsg/reservation/application/dto/event/ReservationCancelledEvent.java b/src/main/java/org/pgsg/reservation/application/dto/event/ReservationCancelledEvent.java
new file mode 100644
index 0000000..7698ee0
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/event/ReservationCancelledEvent.java
@@ -0,0 +1,19 @@
+package org.pgsg.reservation.application.dto.event;
+
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+
+import java.util.UUID;
+
+public record ReservationCancelledEvent(
+ UUID reservationId,
+ UUID productId,
+ String reason
+) {
+ public static ReservationCancelledEvent from(Reservation reservation, String reason) {
+ return new ReservationCancelledEvent(
+ reservation.getId(),
+ reservation.getProductInfo().getProductId(),
+ reason
+ );
+ }
+}
diff --git a/src/main/java/org/pgsg/reservation/application/dto/event/ReservationCompletedEvent.java b/src/main/java/org/pgsg/reservation/application/dto/event/ReservationCompletedEvent.java
new file mode 100644
index 0000000..6a0d246
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/event/ReservationCompletedEvent.java
@@ -0,0 +1,28 @@
+package org.pgsg.reservation.application.dto.event;
+
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import java.util.UUID;
+
+public record ReservationCompletedEvent(
+ UUID reservationId,
+ UUID productId,
+ String productName,
+ Integer productPrice,
+ UUID sellerId,
+ String sellerNickName,
+ UUID buyerId,
+ String buyerNickName
+) {
+ public static ReservationCompletedEvent from(Reservation reservation) {
+ return new ReservationCompletedEvent(
+ reservation.getId(),
+ reservation.getProductInfo().getProductId(),
+ reservation.getProductInfo().getProductName(),
+ reservation.getProductInfo().getProductPrice(),
+ reservation.getSellerInfo().getSellerId(),
+ reservation.getSellerInfo().getSellerName(),
+ reservation.getBuyerInfo() != null ? reservation.getBuyerInfo().getBuyerId() : null,
+ reservation.getBuyerInfo() != null ? reservation.getBuyerInfo().getBuyerName() : "Unknown"
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/event/ReservationFailedEvent.java b/src/main/java/org/pgsg/reservation/application/dto/event/ReservationFailedEvent.java
new file mode 100644
index 0000000..cf3cac2
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/event/ReservationFailedEvent.java
@@ -0,0 +1,8 @@
+package org.pgsg.reservation.application.dto.event;
+
+import java.util.UUID;
+
+public record ReservationFailedEvent(
+ UUID id,
+ String reason
+) {}
diff --git a/src/main/java/org/pgsg/reservation/application/dto/info/ReservationCandidateInfo.java b/src/main/java/org/pgsg/reservation/application/dto/info/ReservationCandidateInfo.java
new file mode 100644
index 0000000..99e3423
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/info/ReservationCandidateInfo.java
@@ -0,0 +1,21 @@
+package org.pgsg.reservation.application.dto.info;
+
+import org.pgsg.reservation.domain.model.reservationcandidate.ReservationCandidate;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record ReservationCandidateInfo(
+ UUID candidateId,
+ String candidateNickname,
+ String status,
+ LocalDateTime createdAt
+) {
+ public static ReservationCandidateInfo from(ReservationCandidate candidate) {
+ return new ReservationCandidateInfo(
+ candidate.getCandidateId(),
+ candidate.getCandidateNickname(),
+ candidate.getStatus().name(),
+ candidate.getCreatedAt()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/info/ReservationStateInfo.java b/src/main/java/org/pgsg/reservation/application/dto/info/ReservationStateInfo.java
new file mode 100644
index 0000000..d5a87f4
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/info/ReservationStateInfo.java
@@ -0,0 +1,22 @@
+package org.pgsg.reservation.application.dto.info;
+
+import lombok.Builder;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Builder
+public record ReservationStateInfo(
+ UUID reservationId,
+ ReservationStatus status,
+ LocalDateTime updatedAt
+) {
+ public static ReservationStateInfo from(Reservation reservation) {
+ return ReservationStateInfo.builder()
+ .reservationId(reservation.getId())
+ .status(reservation.getStatus())
+ .updatedAt(reservation.getModifiedAt())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/query/ReservationSearchQuery.java b/src/main/java/org/pgsg/reservation/application/dto/query/ReservationSearchQuery.java
new file mode 100644
index 0000000..3c5a35e
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/query/ReservationSearchQuery.java
@@ -0,0 +1,14 @@
+package org.pgsg.reservation.application.dto.query;
+
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+
+import java.util.UUID;
+
+
+public record ReservationSearchQuery(
+ String sellerName,
+ String buyerName,
+ String productName,
+ ReservationStatus status,
+ UUID productId
+) {}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/result/CustomPage.java b/src/main/java/org/pgsg/reservation/application/dto/result/CustomPage.java
new file mode 100644
index 0000000..eb3631a
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/result/CustomPage.java
@@ -0,0 +1,36 @@
+package org.pgsg.reservation.application.dto.result;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class CustomPage {
+ private List content;
+ private int pageNumber;
+ private int pageSize;
+ private long totalElements;
+
+ public static CustomPage from(Page page) {
+ return new CustomPage<>(
+ page.getContent(),
+ page.getNumber(),
+ page.getSize(),
+ page.getTotalElements()
+ );
+ }
+
+ public CustomPage map(Function super T, ? extends U> converter) {
+ List convertedContent = this.content.stream()
+ .map(converter)
+ .collect(Collectors.toList());
+ return new CustomPage<>(convertedContent, this.pageNumber, this.pageSize, this.totalElements);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/result/ReservationCreateResult.java b/src/main/java/org/pgsg/reservation/application/dto/result/ReservationCreateResult.java
new file mode 100644
index 0000000..6703276
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/result/ReservationCreateResult.java
@@ -0,0 +1,31 @@
+package org.pgsg.reservation.application.dto.result;
+
+import lombok.Builder;
+import lombok.Getter;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Getter
+@Builder
+public class ReservationCreateResult {
+ private UUID reservationId;
+ private String status;
+ private String productName;
+ private String sellerName;
+ private LocalDateTime createdAt;
+
+ /**
+ * 예약 생성 엔티티로부터 Result DTO 변환
+ */
+ public static ReservationCreateResult from(Reservation reservation) {
+ return ReservationCreateResult.builder()
+ .reservationId(reservation.getId())
+ .status(reservation.getStatus().name())
+ .productName(reservation.getProductInfo().getProductName())
+ .sellerName(reservation.getSellerInfo().getSellerName())
+ .createdAt(reservation.getCreatedAt())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/result/ReservationDetailResult.java b/src/main/java/org/pgsg/reservation/application/dto/result/ReservationDetailResult.java
new file mode 100644
index 0000000..0beb602
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/result/ReservationDetailResult.java
@@ -0,0 +1,109 @@
+package org.pgsg.reservation.application.dto.result;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record ReservationDetailResult(
+ UUID reservationId,
+ ReservationStatus status,
+ ProductInfo product,
+ SellerInfo seller,
+ BuyerInfo buyer,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+ @JsonCreator
+ public ReservationDetailResult(
+ @JsonProperty("reservationId") UUID reservationId,
+ @JsonProperty("status") ReservationStatus status,
+ @JsonProperty("product") ProductInfo product,
+ @JsonProperty("seller") SellerInfo seller,
+ @JsonProperty("buyer") BuyerInfo buyer,
+ @JsonProperty("createdAt") LocalDateTime createdAt,
+ @JsonProperty("updatedAt") LocalDateTime updatedAt
+ ) {
+ this.reservationId = reservationId;
+ this.status = status;
+ this.product = product;
+ this.seller = seller;
+ this.buyer = buyer;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+
+ public static record ProductInfo(
+ UUID productId,
+ String productName,
+ Integer price,
+ LocalDateTime endTime
+ ) {
+ @JsonCreator
+ public ProductInfo(
+ @JsonProperty("productId") UUID productId,
+ @JsonProperty("productName") String productName,
+ @JsonProperty("price") Integer price,
+ @JsonProperty("endTime") LocalDateTime endTime
+ ) {
+ this.productId = productId;
+ this.productName = productName;
+ this.price = price;
+ this.endTime = endTime;
+ }
+ }
+
+ public static record SellerInfo(
+ UUID sellerId,
+ String sellerName
+ ) {
+ @JsonCreator
+ public SellerInfo(
+ @JsonProperty("sellerId") UUID sellerId,
+ @JsonProperty("sellerName") String sellerName
+ ) {
+ this.sellerId = sellerId;
+ this.sellerName = sellerName;
+ }
+ }
+
+ public static record BuyerInfo(
+ UUID buyerId,
+ String buyerName
+ ) {
+ @JsonCreator
+ public BuyerInfo(
+ @JsonProperty("buyerId") UUID buyerId,
+ @JsonProperty("buyerName") String buyerName
+ ) {
+ this.buyerId = buyerId;
+ this.buyerName = buyerName;
+ }
+ }
+
+ public static ReservationDetailResult from(Reservation reservation) {
+ return new ReservationDetailResult(
+ reservation.getId(),
+ reservation.getStatus(),
+ new ProductInfo(
+ reservation.getProductInfo().getProductId(),
+ reservation.getProductInfo().getProductName(),
+ reservation.getProductInfo().getProductPrice(),
+ reservation.getProductInfo().getEndTime()
+ ),
+ new SellerInfo(
+ reservation.getSellerInfo().getSellerId(),
+ reservation.getSellerInfo().getSellerName()
+ ),
+ reservation.getBuyerInfo() != null ? new BuyerInfo(
+ reservation.getBuyerInfo().getBuyerId(),
+ reservation.getBuyerInfo().getBuyerName()
+ ) : null,
+ reservation.getCreatedAt(),
+ reservation.getModifiedAt()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/dto/result/ReservationSearchResult.java b/src/main/java/org/pgsg/reservation/application/dto/result/ReservationSearchResult.java
new file mode 100644
index 0000000..382a1b5
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/dto/result/ReservationSearchResult.java
@@ -0,0 +1,46 @@
+package org.pgsg.reservation.application.dto.result;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record ReservationSearchResult(
+ UUID reservationId,
+ String productName,
+ String sellerName,
+ String buyerName,
+ ReservationStatus status,
+ LocalDateTime createdAt
+) {
+ @JsonCreator
+ public ReservationSearchResult(
+ @JsonProperty("reservationId") UUID reservationId,
+ @JsonProperty("productName") String productName,
+ @JsonProperty("sellerName") String sellerName,
+ @JsonProperty("buyerName") String buyerName,
+ @JsonProperty("status") ReservationStatus status,
+ @JsonProperty("createdAt") LocalDateTime createdAt
+ ) {
+ this.reservationId = reservationId;
+ this.productName = productName;
+ this.sellerName = sellerName;
+ this.buyerName = buyerName;
+ this.status = status;
+ this.createdAt = createdAt;
+ }
+
+ public static ReservationSearchResult from(Reservation reservation) {
+ return new ReservationSearchResult(
+ reservation.getId(),
+ reservation.getProductInfo().getProductName(),
+ reservation.getSellerInfo().getSellerName(),
+ reservation.getBuyerInfo() != null ? reservation.getBuyerInfo().getBuyerName() : null,
+ reservation.getStatus(),
+ reservation.getCreatedAt()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/application/service/ReservationService.java b/src/main/java/org/pgsg/reservation/application/service/ReservationService.java
new file mode 100644
index 0000000..7c737fc
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/application/service/ReservationService.java
@@ -0,0 +1,296 @@
+package org.pgsg.reservation.application.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.pgsg.reservation.application.dto.command.*;
+import org.pgsg.reservation.application.dto.result.CustomPage;
+import org.pgsg.reservation.application.dto.result.ReservationDetailResult;
+import org.pgsg.reservation.infrastructure.listener.dto.ReservationEventPublisher;
+import org.pgsg.reservation.application.dto.info.ReservationStateInfo;
+import org.pgsg.reservation.application.dto.query.ReservationSearchQuery;
+import org.pgsg.reservation.application.dto.result.ReservationCreateResult;
+import org.pgsg.reservation.application.dto.result.ReservationSearchResult;
+import org.pgsg.reservation.domain.dto.ReservationSearchCriteria;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.pgsg.reservation.domain.model.reservation.*;
+import org.pgsg.reservation.domain.model.reservationcandidate.ReservationCandidate;
+import org.pgsg.reservation.domain.model.reservationhistory.ReservationHistory;
+import org.pgsg.reservation.domain.repository.ReservationHistoryRepository;
+import org.pgsg.reservation.domain.service.ReservationDomainService;
+import org.pgsg.reservation.domain.repository.ReservationRepository;
+import org.pgsg.reservation.application.dto.info.ReservationCandidateInfo;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.cache.annotation.Caching;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ReservationService {
+
+ private final ReservationRepository reservationRepository;
+ private final ReservationDomainService reservationDomainService;
+ private final ReservationHistoryRepository reservationHistoryRepository;
+ private final ReservationEventPublisher reservationEventPublisher;
+
+ // 도메인 서비스 호출 전까지의 작업은 트랜잭션 밖으로 분리(추후 고도화 작업시)
+ @Transactional
+ public ReservationCreateResult createReservation(ReservationCreateCommand command) {
+ try {
+ if (reservationRepository.existsByProductId(command.productId())) {
+ throw new ReservationException(ReservationErrorCode.ALREADY_EXISTS);
+ }
+
+ SellerInfo seller = SellerInfo.of(command.sellerId(), "test");
+ ProductInfo product = ProductInfo.of(
+ command.productId(),
+ command.price(),
+ command.productName(),
+ command.endTime()
+ );
+
+ Reservation reservation = reservationDomainService.createReservation(
+ seller,
+ product
+ );
+
+ Reservation saved;
+ try {
+ saved = reservationRepository.save(reservation);
+ }catch (DataIntegrityViolationException e) {
+ throw new ReservationException(ReservationErrorCode.ALREADY_EXISTS);
+ }
+
+ return ReservationCreateResult.from(saved);
+
+ } catch (DataIntegrityViolationException e) {
+ // 동시에 들어온 요청으로 인해 유니크 제약 조건 위반 시 409 에러 발생
+ throw new ReservationException(ReservationErrorCode.ALREADY_EXISTS);
+ }
+ }
+
+ // 예약 목록 조회
+ @Transactional(readOnly = true)
+ @Cacheable(value = "reservations",
+ key = "#userId.toString() + ':' + #role + ':' + " +
+ "(#query.sellerName() != null ? #query.sellerName() : '') + ':' + " +
+ "(#query.buyerName() != null ? #query.buyerName() : '') + ':' + " +
+ "(#query.productName() != null ? #query.productName() : '') + ':' + " +
+ "(#query.status() != null ? #query.status().name() : '') + ':' + " +
+ "(#query.productId() != null ? #query.productId().toString() : '') + ':' + " +
+ "#pageable.pageNumber + ':' + #pageable.pageSize")
+ public CustomPage getSearchReservations(
+ UUID userId,
+ String role,
+ ReservationSearchQuery query,
+ Pageable pageable
+ ) {
+ // 권한에 따른 조회 범위 결정 로직을 도메인 모델로 전달
+ SearchPolicy policy = reservationDomainService.getReservations(userId, role);
+
+ ReservationSearchCriteria criteria = new ReservationSearchCriteria(
+ query.sellerName(),
+ query.buyerName(),
+ query.productName(),
+ query.status(),
+ query.productId(),
+ null, // startDateTime
+ null, // endDateTime
+ policy
+ );
+
+ Page reservations = reservationRepository.findByCriteria(criteria, pageable);
+
+ return CustomPage.from(reservations.map(ReservationSearchResult::from));
+ }
+
+
+ // 예약 상세 조회
+ @Cacheable(
+ value = "reservationDetail",
+ key = "#reservationId.toString()",
+ unless = "#result == null"
+ )
+ @Transactional(readOnly = true)
+ public ReservationDetailResult getReservationDetail(UUID reservationId, UUID userId, String role) {
+ // 엔티티 조회
+ Reservation reservation = reservationRepository.findById(reservationId)
+ .orElseThrow(() -> new RuntimeException("해당 예약을 찾을 수 없습니다. ID: " + reservationId));
+ // 도메인 서비스를 통한 권한 검증
+ reservationDomainService.validateDetailAccess(reservation, userId, role);
+
+ return ReservationDetailResult.from(reservation);
+ }
+
+ // 예약 신청
+ @Transactional
+ @Caching(evict = {
+ @CacheEvict(value = "reservationDetail", key = "#command.reservationId().toString()"),
+ })
+ public ReservationCandidateInfo proceedApplyTransaction(ReservationApplyCommand command) {
+
+ // 예약 엔티티 조회
+ Reservation reservation = reservationRepository.findById(command.reservationId())
+ .orElseThrow(() -> new ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND));
+
+ // 도메인 서비스를 통한 신청 로직 (후보자 생성 및 추가)
+ ReservationCandidate candidate = reservationDomainService.addCandidate(reservation, command.userId(), command.nickname());
+
+ // 변경사항 저장과 예외 감시
+ try {
+ reservationRepository.save(reservation);
+ } catch (DataIntegrityViolationException e) {
+ if (isDuplicateApplyViolation(e)) {
+ throw new ReservationException(ReservationErrorCode.ALREADY_APPLIED);
+ }
+ throw e;
+ }
+ return ReservationCandidateInfo.from(candidate);
+ }
+
+ // 구매자 취소 처리 로직
+ @Transactional
+ @Caching(evict = {
+ @CacheEvict(value = "reservationDetail", key = "#command.reservationId().toString()"),
+ })
+ public ReservationStateInfo cancelByBuyer(ReservationCancelCommand command) {
+ Reservation reservation = findById(command.reservationId());
+
+ ReservationHistory history = reservationDomainService.cancelByBuyer(
+ reservation,
+ command.userId(),
+ command.role(),
+ command.reason()
+ );
+
+ reservationHistoryRepository.save(history);
+
+ return ReservationStateInfo.from(reservation);
+ }
+
+ // 추후 결제 시스템 연동시 트리거 발동
+ @Transactional
+ @Caching(evict = {
+ @CacheEvict(value = "reservationDetail", key = "#command.reservationId().toString()"),
+ })
+ public ReservationStateInfo confirmPayment(ReservationConfirmCommand command) {
+ Reservation reservation = findById(command.reservationId());
+
+ ReservationHistory history = reservationDomainService.confirmPayment(
+ reservation,
+ command.userId(),
+ command.role()
+ );
+
+ if (history != null) {
+ reservationHistoryRepository.save(history);
+ } else {
+ log.info("이미 결제 완료 처리된 예약입니다(중복 요청 무시): reservationId={}", reservation.getId());
+ }
+
+ return ReservationStateInfo.from(reservation);
+ }
+
+ // 판매자 취소 로직
+ @Transactional
+ @Caching(evict = {
+ @CacheEvict(value = "reservationDetail", key = "#command.reservationId().toString()"),
+ })
+ public ReservationStateInfo cancelBySeller(ReservationCancelCommand command) {
+ Reservation reservation = findById(command.reservationId());
+
+ ReservationHistory history = reservationDomainService.cancelBySeller(
+ reservation,
+ command.userId(),
+ command.role(),
+ command.reason()
+ );
+
+ reservationHistoryRepository.save(history);
+ reservationEventPublisher.publishReservationCancelled(reservation,command.reason());
+
+ return ReservationStateInfo.from(reservation);
+ }
+
+ // 예약 만료
+ @Transactional
+ @Caching(evict = {
+ @CacheEvict(value = "reservationDetail", key = "#command.reservationId().toString()"),
+ })
+ public ReservationStateInfo expireByAdmin(ReservationExpireCommand command) {
+ Reservation reservation = findById(command.reservationId());
+
+ ReservationHistory history = reservationDomainService.expireByAdmin(
+ reservation,
+ command.userId(),
+ command.role(),
+ command.targetStatus(),
+ command.reason()
+ );
+
+ reservationHistoryRepository.save(history);
+
+ return ReservationStateInfo.from(reservation);
+ }
+
+ // 예약 완료
+ @Transactional
+ @Caching(evict = {
+ @CacheEvict(value = "reservationDetail", allEntries = true),
+ })
+ public ReservationStateInfo completeReservation(ReservationConfirmCommand command) {
+ // 예약 엔티티 조회
+ Reservation reservation = findById(command.reservationId());
+
+ ReservationHistory history = reservationDomainService.completeReservation(reservation,command.userId(),command.role());
+
+ reservationHistoryRepository.save(history);
+ reservationEventPublisher.publishReservationCompleted(reservation);
+
+ return ReservationStateInfo.from(reservation);
+ }
+
+ // 거래 완료
+ @Transactional
+ @Caching(evict = {
+ @CacheEvict(value = "reservationDetail", key = "#command.reservationId().toString()"),
+ })
+ public ReservationStateInfo confirmTrade(ReservationConfirmCommand command) {
+
+ Reservation reservation = findById(command.reservationId());
+
+ ReservationHistory history = reservationDomainService.confirmTrade(
+ reservation,
+ command.userId(),
+ command.role()
+ );
+
+ if (history != null) {
+ reservationHistoryRepository.save(history);
+ }
+
+ return ReservationStateInfo.from(reservation);
+ }
+
+ /**
+ * 공통 ID 조회 메서드
+ */
+ private Reservation findById(UUID reservationId) {
+ return reservationRepository.findById(reservationId)
+ .orElseThrow(() -> new ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND));
+ }
+
+ private boolean isDuplicateApplyViolation(DataIntegrityViolationException e) {
+ String message = e.getMostSpecificCause().getMessage();
+ return message != null && message.contains("uk_reservation_candidate_user_id");
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/dto/ReservationSearchCriteria.java b/src/main/java/org/pgsg/reservation/domain/dto/ReservationSearchCriteria.java
new file mode 100644
index 0000000..26846e8
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/dto/ReservationSearchCriteria.java
@@ -0,0 +1,19 @@
+package org.pgsg.reservation.domain.dto;
+
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import org.pgsg.reservation.domain.model.reservation.SearchPolicy;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record ReservationSearchCriteria(
+ String sellerName,
+ String buyerName,
+ String productName,
+ ReservationStatus status,
+ UUID productId,
+ LocalDateTime startDateTime,
+ LocalDateTime endDateTime,
+ SearchPolicy policy
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/exception/ReservationErrorCode.java b/src/main/java/org/pgsg/reservation/domain/exception/ReservationErrorCode.java
new file mode 100644
index 0000000..b4fe920
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/exception/ReservationErrorCode.java
@@ -0,0 +1,43 @@
+package org.pgsg.reservation.domain.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.pgsg.common.exception.ErrorCode;
+
+@Getter
+@RequiredArgsConstructor
+public enum ReservationErrorCode implements ErrorCode {
+
+ // 유효성 검사 (R001~R003 계열)
+ INVALID_INPUT("[reservation.validation.invalid-input]", "input"),
+ INVALID_STATUS("[reservation.validation.invalid-status]", "status"),
+ INVALID_SELECT_STATUS("[reservation.validation.invalid-select-status]", "selectStatus"),
+
+ // 서비스 예외 - 권한 및 상태 (R004~R005 계열)
+ CANNOT_CHANGE_STATUS("[reservation.exception.cannot-change-status]", "status"),
+ UNAUTHORIZED_ACCESS("[reservation.exception.access-denied]", "role"),
+
+ // 서비스 예외 - 조회 실패 (R006 계열)
+ RESERVATION_NOT_FOUND("[reservation.exception.not-found.reservation]", "reservationId"),
+
+ // 서비스 예외 - 충돌 및 중복 (R007~R009 계열)
+ DUPLICATE_RESERVATION("[reservation.exception.conflict.duplicate-reservation]", "productId"),
+ ALREADY_APPLIED("[reservation.exception.conflict.already-applied]", "buyerId"),
+ ALREADY_EXISTS("[reservation.exception.conflict.already-exists]", "productId"),
+
+ // 분산 락 관련 예외 (R010~R011 계열 추가)
+ RESERVATION_BUSY("[reservation.exception.lock.busy]", "reservationId"),
+ RESERVATION_INTERRUPTED("[reservation.exception.lock.interrupted]", "reservationId");
+
+ private final String errorKey;
+ private final String field;
+
+ public static ReservationErrorCode fromErrorKey(String errorKey) {
+ for (ReservationErrorCode errorCode : values()) {
+ if (errorCode.getErrorKey().equals(errorKey)) {
+ return errorCode;
+ }
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/exception/ReservationException.java b/src/main/java/org/pgsg/reservation/domain/exception/ReservationException.java
new file mode 100644
index 0000000..863bc48
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/exception/ReservationException.java
@@ -0,0 +1,20 @@
+package org.pgsg.reservation.domain.exception;
+
+import org.pgsg.common.exception.CustomException;
+import org.pgsg.common.exception.ErrorCode;
+
+public class ReservationException extends CustomException {
+
+ public ReservationException(ErrorCode errorCode) {
+ super(errorCode, null);
+ }
+
+ public ReservationException(ErrorCode errorCode, String field) {
+ super(errorCode, field);
+ }
+
+ public ReservationException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode, null);
+ this.initCause(cause);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/exception/ReservationExceptionHandler.java b/src/main/java/org/pgsg/reservation/domain/exception/ReservationExceptionHandler.java
new file mode 100644
index 0000000..2cc4e02
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/exception/ReservationExceptionHandler.java
@@ -0,0 +1,112 @@
+package org.pgsg.reservation.domain.exception;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.pgsg.common.exception.CustomException;
+import org.pgsg.common.exception.ErrorConfigProperties;
+import org.pgsg.common.exception.GlobalExceptionAdvice;
+import org.pgsg.common.response.ErrorResponse;
+import org.slf4j.MDC;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Map;
+
+@Slf4j
+@RestControllerAdvice
+@RequiredArgsConstructor
+public class ReservationExceptionHandler implements GlobalExceptionAdvice {
+
+ private final ErrorConfigProperties errorConfigProperties;
+
+ /**
+ * @Valid 검증 예외 처리
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationException(MethodArgumentNotValidException e) {
+ FieldError fieldError = e.getBindingResult().getFieldError();
+
+ if (fieldError == null) {
+ String globalErrorKey = e.getBindingResult().getGlobalError() != null
+ ? e.getBindingResult().getGlobalError().getDefaultMessage() : null;
+ return buildResponse(globalErrorKey, null, e);
+ }
+
+ String errorKey = fieldError.getDefaultMessage();
+ ReservationErrorCode errorCode = ReservationErrorCode.fromErrorKey(errorKey);
+
+ if (errorCode != null) {
+ return buildResponse(errorCode.getErrorKey(), errorCode.getField(), e);
+ }
+
+ return buildResponse(errorKey, fieldError.getField(), e);
+ }
+
+ /**
+ * 예약 도메인 비즈니스 예외 처리
+ */
+ @ExceptionHandler(ReservationException.class)
+ public ResponseEntity handleReservationException(CustomException e) {
+ String field = e.getField();
+ if (field == null && e.getErrorCode() instanceof ReservationErrorCode rec) {
+ field = rec.getField();
+ }
+ return buildResponse(e.getErrorCode().getErrorKey(), field, e);
+ }
+
+ /**
+ * 공통 에러 응답 빌더
+ */
+ private ResponseEntity buildResponse(String errorKey, String field, Exception e) {
+ // errorKey가 null인 경우 시스템 에러 응답을 직접 생성하여 리턴합니다.
+ if (errorKey == null) {
+ return ResponseEntity
+ .status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "SYSTEM-500", field, "정의되지 않은 서버 에러가 발생했습니다."));
+ }
+
+ // 양 끝의 대괄호를 제거한 키를 생성합니다.
+ String normalizedKey = (errorKey.startsWith("[") && errorKey.endsWith("]"))
+ ? errorKey.substring(1, errorKey.length() - 1)
+ : errorKey;
+
+ // 반대로 대괄호가 포함된 형태의 키도 준비하여 바인딩 유연성을 확보합니다.
+ String bracketKey = "[" + normalizedKey + "]";
+
+ // 우선 대괄호가 없는 키로 조회를 시도하고, 결과가 없으면 대괄호가 포함된 키로 다시 조회합니다.
+ ErrorConfigProperties.ErrorDetail detail = errorConfigProperties.getConfigs().entrySet().stream()
+ .filter(entry -> entry.getKey().equals(normalizedKey) || ("[" + entry.getKey() + "]").equals(errorKey))
+ .map(Map.Entry::getValue)
+ .findFirst()
+ .orElse(null);
+
+ // 두 가지 방식 모두 설정값 조회에 실패한 경우 KEY-NOT-FOUND 응답을 반환합니다.
+ if (detail == null) {
+ // 디버깅을 위해 현재 맵에 들어있는 키들을 로그로 찍어보세요.
+ log.error("현재 로드된 설정 키 목록: {}", errorConfigProperties.getConfigs().keySet());
+ log.error("[TraceID: {}] Undefined Error Key: {}", MDC.get("traceId"), normalizedKey);
+
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "KEY-NOT-FOUND", field, "설정에서 찾을 수 없는 키입니다: " + normalizedKey));
+ }
+
+ log.error("[TraceID: {}] Exception: field={}, code={}, message={}",
+ MDC.get("traceId"), field, detail.getCode(), detail.getMessage());
+
+ // 설정된 status 값을 바탕으로 HttpStatus 객체를 생성하며 유효하지 않을 경우 500 에러를 기본값으로 사용합니다.
+ HttpStatus status = HttpStatus.resolve(detail.getStatus());
+ if (status == null) {
+ log.warn("[TraceID: {}] Invalid HTTP Status Code in config: status={}, errorKey={}",
+ MDC.get("traceId"), detail.getStatus(), normalizedKey);
+ status = HttpStatus.INTERNAL_SERVER_ERROR;
+ }
+
+ return ResponseEntity
+ .status(status)
+ .body(ErrorResponse.of(status, detail.getCode(), field, detail.getMessage()));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservation/BuyerInfo.java b/src/main/java/org/pgsg/reservation/domain/model/reservation/BuyerInfo.java
new file mode 100644
index 0000000..617e358
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservation/BuyerInfo.java
@@ -0,0 +1,14 @@
+package org.pgsg.reservation.domain.model.reservation;
+
+import jakarta.persistence.Embeddable;
+import lombok.*;
+import java.util.UUID;
+
+@Embeddable
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(staticName = "of")
+public class BuyerInfo {
+ private UUID buyerId;
+ private String buyerName;
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservation/ProductInfo.java b/src/main/java/org/pgsg/reservation/domain/model/reservation/ProductInfo.java
new file mode 100644
index 0000000..be6fb0d
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservation/ProductInfo.java
@@ -0,0 +1,21 @@
+package org.pgsg.reservation.domain.model.reservation;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Embeddable
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(staticName = "of")
+public class ProductInfo {
+ @Column(nullable = false, unique = true)
+ private UUID productId;
+ private Integer productPrice;
+ private String productName;
+ @Column(nullable = false)
+ private LocalDateTime endTime;
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservation/Reservation.java b/src/main/java/org/pgsg/reservation/domain/model/reservation/Reservation.java
new file mode 100644
index 0000000..b922009
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservation/Reservation.java
@@ -0,0 +1,164 @@
+package org.pgsg.reservation.domain.model.reservation;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.pgsg.common.domain.BaseEntity;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+import org.pgsg.reservation.domain.model.reservationcandidate.ReservationCandidate;
+import org.pgsg.reservation.domain.model.reservationcandidate.ReservationCandidateStatus;
+
+import java.util.*;
+
+@Entity
+@Getter
+@Table(name = "p_reservations")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Reservation extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ // 낙관적 락을 위한 버전 추가(추후 분산 락으로 수정 예정)
+ @Version
+ private Long version;
+
+ @Enumerated(EnumType.STRING)
+ private ReservationStatus status;
+
+ @Embedded
+ @AttributeOverrides({
+ @AttributeOverride(name = "buyerId", column = @Column(name = "buyer_id", nullable = true)),
+ @AttributeOverride(name = "buyerName", column = @Column(name = "buyer_name", nullable = true))
+ })
+ private BuyerInfo buyerInfo;
+
+ @Embedded
+ private SellerInfo sellerInfo;
+
+ @Embedded
+ private ProductInfo productInfo;
+
+ // 예약 후보자들
+ @OneToMany(mappedBy = "reservation", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List candidates = new ArrayList<>();
+
+ public List getCandidates() {
+ return Collections.unmodifiableList(candidates);
+ }
+
+ public void addCandidate(ReservationCandidate candidate) {
+ validateStatus(ReservationStatus.AVAILABLE);
+ this.candidates.add(candidate);
+ }
+
+ public void removeCandidate(ReservationCandidate candidate) {
+ validateStatus(ReservationStatus.AVAILABLE);
+ this.candidates.remove(candidate);
+ }
+
+ // 예약 생성: 초기 상태 AVAILABLE
+ public static Reservation create(BuyerInfo buyer, SellerInfo seller, ProductInfo product) {
+ Objects.requireNonNull(seller, "seller must not be null");
+ Objects.requireNonNull(product, "product must not be null");
+
+ Reservation reservation = new Reservation();
+ reservation.buyerInfo = buyer;
+ reservation.sellerInfo = seller;
+ reservation.productInfo = product;
+ reservation.status = ReservationStatus.AVAILABLE;
+
+ return reservation;
+ }
+
+ // 임시 예약: AVAILABLE 상태에서 결제 대기(점유) 단계로 진입
+ public void markAsPending() {
+ validateStatus(ReservationStatus.AVAILABLE);
+ this.status = ReservationStatus.PENDING;
+ }
+
+ // 결제 완료: PENDING(임시 예약) 상태일 때만 가능
+ public void markAsPaid() {
+ validateStatus(ReservationStatus.PENDING);
+ this.status = ReservationStatus.PAID;
+ }
+
+ // 예약 완료: PAID(결제 완료) 상태에서 채팅 수락 시
+ public void complete() {
+ validateStatus(ReservationStatus.PAID);
+ this.status = ReservationStatus.COMPLETED;
+ }
+
+ // 거래 복구: COMPLETED 상태에서 취소 발생 시 AVAILABLE로 복구
+ public void rollbackToAvailable() {
+ validateStatus(ReservationStatus.COMPLETED);
+ this.status = ReservationStatus.AVAILABLE;
+ }
+
+ // 구매자 취소: PENDING 또는 PAID 상태에서 구매자가 취소
+ public void cancelByBuyer() {
+ validateStatus(ReservationStatus.AVAILABLE, ReservationStatus.PENDING, ReservationStatus.PAID);
+ this.status = ReservationStatus.CANCELLED_BY_BUYER;
+ }
+
+ // 대기자가 없을 경우, 다시 누구나 신청 가능한 상태로 복구
+ public void reopen() {
+ // 현재 상태가 취소된 상태여야 재오픈 가능 (보안 및 로직 검증)
+ validateStatus(ReservationStatus.CANCELLED_BY_BUYER);
+
+ this.status = ReservationStatus.AVAILABLE;
+ this.buyerInfo = null; // 기존 구매자 정보 제거
+ }
+
+ // 판매자 취소: AVAILABLE,PENDING,PAID 상태에서 판매자가 취소(영구 종료)
+ public void cancelBySeller() {
+ validateStatus(ReservationStatus.AVAILABLE,ReservationStatus.PENDING, ReservationStatus.PAID);
+ this.status = ReservationStatus.CANCELLED_BY_SELLER;
+ }
+
+ // 거래 완료 : COMPLETED에서만 CLOSED를 할 수 있다
+ public void confirmTrade() {
+ validateStatus(ReservationStatus.COMPLETED);
+ this.status = ReservationStatus.CLOSED;
+ }
+
+ // 다음 순번 구매자로 교체
+ public void changeToNextBuyer(ReservationCandidate nextCandidate) {
+ // 현재 변경 가능한 상태인지 검증 (AVAILABLE 혹은 이전 구매자 취소 상태)
+ validateStatus(ReservationStatus.AVAILABLE, ReservationStatus.CANCELLED_BY_BUYER);
+
+ // 인자 및 후보자 유효성 검증
+ if (nextCandidate == null) {
+ throw new IllegalArgumentException("nextBuyer must not be null");
+ }
+ if (!this.candidates.contains(nextCandidate)) {
+ throw new ReservationException(ReservationErrorCode.INVALID_STATUS);
+ }
+ if (nextCandidate.getStatus() != ReservationCandidateStatus.WAITING) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+
+ // 순서 변경: 먼저 예약 상태를 PENDING으로 전환하여 selected()의 검증을 통과시킴
+ this.status = ReservationStatus.PENDING;
+ this.buyerInfo = BuyerInfo.of(nextCandidate.getCandidateId(), nextCandidate.getCandidateNickname());
+
+ // 후보자 상태를 SELECTED로 변경
+ nextCandidate.selected();
+ }
+
+ // 상태 검증: 여러 허용 상태 중 하나라도 만족하는지 확인
+ private void validateStatus(ReservationStatus... expectedStatuses) {
+ // 판매자 취소나 최종 종료 상태인 경우 절대 변경 불가
+ if (this.status != null && !this.status.isMutable()) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+
+ boolean isValid = Arrays.stream(expectedStatuses)
+ .anyMatch(expected -> this.status == expected);
+
+ if (!isValid) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservation/ReservationStatus.java b/src/main/java/org/pgsg/reservation/domain/model/reservation/ReservationStatus.java
new file mode 100644
index 0000000..2dbb8cd
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservation/ReservationStatus.java
@@ -0,0 +1,37 @@
+package org.pgsg.reservation.domain.model.reservation;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+
+import java.util.Arrays;
+
+@Getter
+@AllArgsConstructor
+public enum ReservationStatus {
+ AVAILABLE("예약 활성화", true),
+ PENDING("임시 예약(결제 전)", true),
+ PAID("결제 완료(채팅 전)", true),
+ COMPLETED("예약 완료(채팅 수락)", true),
+ CANCELLED_BY_BUYER("구매자 사유 취소", true), // 변경 가능 (AVAILABLE로 복구 등)
+ CANCELLED_BY_SELLER("판매자 사유 취소", false), // 변경 불가 (종료)
+ CLOSED("최종 종료", false); // 변경 불가
+
+ private final String description;
+ private final boolean isMutable; // 상태 변경이 가능한 상태인지 여부
+
+ public static ReservationStatus find(String statusName) {
+ return Arrays.stream(values())
+ .filter(s -> s.name().equalsIgnoreCase(statusName))
+ .findFirst()
+ .orElseThrow(() -> new ReservationException(ReservationErrorCode.INVALID_STATUS));
+ }
+
+ /**
+ * 판매자 취소와 같은 종료 상태인지 확인
+ */
+ public boolean isFinalStatus() {
+ return !this.isMutable;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservation/SearchPolicy.java b/src/main/java/org/pgsg/reservation/domain/model/reservation/SearchPolicy.java
new file mode 100644
index 0000000..915a956
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservation/SearchPolicy.java
@@ -0,0 +1,22 @@
+package org.pgsg.reservation.domain.model.reservation;
+
+import java.util.UUID;
+
+public record SearchPolicy(
+ UUID accessUserId,
+ boolean isUserFilter
+) {
+ public SearchPolicy {
+ if (isUserFilter && accessUserId == null) {
+ throw new IllegalArgumentException("accessUserId is required for user filter.");
+ }
+ }
+
+ public static SearchPolicy user(UUID userId) {
+ return new SearchPolicy(userId, true);
+ }
+
+ public static SearchPolicy all() {
+ return new SearchPolicy(null, false);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservation/SellerInfo.java b/src/main/java/org/pgsg/reservation/domain/model/reservation/SellerInfo.java
new file mode 100644
index 0000000..0eb6d04
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservation/SellerInfo.java
@@ -0,0 +1,14 @@
+package org.pgsg.reservation.domain.model.reservation;
+
+import jakarta.persistence.Embeddable;
+import lombok.*;
+import java.util.UUID;
+
+@Embeddable
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(staticName = "of")
+public class SellerInfo {
+ private UUID sellerId;
+ private String sellerName;
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservationcandidate/ReservationCandidate.java b/src/main/java/org/pgsg/reservation/domain/model/reservationcandidate/ReservationCandidate.java
new file mode 100644
index 0000000..1552bf7
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservationcandidate/ReservationCandidate.java
@@ -0,0 +1,92 @@
+package org.pgsg.reservation.domain.model.reservationcandidate;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.pgsg.common.domain.BaseEntity;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import java.util.Objects;
+
+import java.util.UUID;
+
+@Entity
+@Getter
+@Table(name = "p_reservation_candidates", uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_reservation_candidate_user_id",
+ columnNames = {"reservation_id", "candidate_id"}
+ )
+})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ReservationCandidate extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "reservation_id")
+ private Reservation reservation;
+
+ @Column(name = "candidate_id", nullable = false)
+ private UUID candidateId; // 후보자 식별자
+
+ @Column(nullable = false)
+ private String candidateNickname; // 후보자 닉네임
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private ReservationCandidateStatus status = ReservationCandidateStatus.WAITING;
+
+ // 예약 후보 생성
+ public static ReservationCandidate create(Reservation reservation, UUID candidateId, String candidateNickname) {
+ verify(reservation, candidateId, candidateNickname);
+ ReservationCandidate candidate = new ReservationCandidate();
+ candidate.reservation = reservation;
+ candidate.candidateId = candidateId;
+ candidate.candidateNickname = candidateNickname;
+ return candidate;
+ }
+
+ // 유효성 검사
+ private static void verify(Reservation reservation, UUID candidateId, String candidateNickname) {
+ Objects.requireNonNull(reservation, "reservation must not be null");
+ Objects.requireNonNull(candidateId, "candidateId must not be null");
+ if (candidateNickname == null || candidateNickname.isBlank()) {
+ throw new IllegalArgumentException("candidateNickname must not be blank");
+ }
+ }
+
+ // 후보 선정: 판매자가 후보를 구매자로 최종 선택했을 때 사용
+ public void selected() {
+ validateReservationPendingStatus();
+ if (this.status != ReservationCandidateStatus.WAITING) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+ this.status = ReservationCandidateStatus.SELECTED;
+ }
+
+ // 후보 취소: 후보자가 대기를 철회하거나 선정에서 제외될 때 사용
+ public void cancel() {
+ if (this.status == ReservationCandidateStatus.CANCELLED) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+
+ // 상태 변경
+ this.status = ReservationCandidateStatus.CANCELLED;
+
+ // 부모 예약 엔티티의 후보 리스트에서 제거
+ if (this.reservation != null) {
+ this.reservation.removeCandidate(this);
+ }
+ }
+
+ // Reservation에서 예약 상태(PENDING)이어야 후보자 선정 가능
+ private void validateReservationPendingStatus() {
+ if (this.reservation == null || this.reservation.getStatus() != ReservationStatus.PENDING) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservationcandidate/ReservationCandidateStatus.java b/src/main/java/org/pgsg/reservation/domain/model/reservationcandidate/ReservationCandidateStatus.java
new file mode 100644
index 0000000..e1022c9
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservationcandidate/ReservationCandidateStatus.java
@@ -0,0 +1,24 @@
+package org.pgsg.reservation.domain.model.reservationcandidate;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+import java.util.Arrays;
+
+@Getter
+@AllArgsConstructor
+public enum ReservationCandidateStatus {
+ WAITING("예약후보 선정 대기 중"),
+ SELECTED("예약후보로 선정 완료됨"),
+ CANCELLED("예약후보로 선정되었다가 취소");
+
+ private final String description;
+
+ public static ReservationCandidateStatus find(String statusName) {
+ return Arrays.stream(values())
+ .filter(s -> s.name().equalsIgnoreCase(statusName))
+ .findFirst()
+ .orElseThrow(() -> new ReservationException(ReservationErrorCode.INVALID_SELECT_STATUS));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/model/reservationhistory/ReservationHistory.java b/src/main/java/org/pgsg/reservation/domain/model/reservationhistory/ReservationHistory.java
new file mode 100644
index 0000000..5587524
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/model/reservationhistory/ReservationHistory.java
@@ -0,0 +1,48 @@
+package org.pgsg.reservation.domain.model.reservationhistory;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.pgsg.common.domain.BaseEntity;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+
+import java.util.UUID;
+
+@Entity
+@Getter
+@Table(name = "p_reservation_histories")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class ReservationHistory extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(nullable = false)
+ private UUID reservationId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private ReservationStatus previousStatus;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private ReservationStatus newStatus;
+
+ private String comment; // 변경 사유
+
+ @Column(nullable = false)
+ private UUID changedBy; // 상태를 변경한 사용자 ID
+
+ // 상태 변경 이력을 생성
+ public static ReservationHistory of(UUID reservationId, ReservationStatus previousStatus,
+ ReservationStatus newStatus, String comment, UUID changedBy) {
+ if (comment == null || comment.isBlank()) {
+ throw new IllegalArgumentException("comment must not be blank");
+ }
+ return new ReservationHistory(null, reservationId, previousStatus, newStatus, comment, changedBy);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/repository/ReservationHistoryRepository.java b/src/main/java/org/pgsg/reservation/domain/repository/ReservationHistoryRepository.java
new file mode 100644
index 0000000..083e6c8
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/repository/ReservationHistoryRepository.java
@@ -0,0 +1,7 @@
+package org.pgsg.reservation.domain.repository;
+
+import org.pgsg.reservation.domain.model.reservationhistory.ReservationHistory;
+
+public interface ReservationHistoryRepository {
+ ReservationHistory save(ReservationHistory history);
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/repository/ReservationRepository.java b/src/main/java/org/pgsg/reservation/domain/repository/ReservationRepository.java
new file mode 100644
index 0000000..a117d62
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/repository/ReservationRepository.java
@@ -0,0 +1,31 @@
+package org.pgsg.reservation.domain.repository;
+
+import org.pgsg.reservation.domain.dto.ReservationSearchCriteria;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface ReservationRepository {
+ Reservation save(Reservation reservation);
+
+ // QueryDSL을 활용한 동적 검색 구현
+ Page findByCriteria(ReservationSearchCriteria criteria, Pageable pageable);
+
+ Optional findById(UUID id);
+
+ // 예약 만료 스케줄링용
+ List findAllByStatusInAndModifiedAtBeforeOrProductInfoEndTimeBefore(
+ List statuses,
+ LocalDateTime threshold,
+ LocalDateTime now
+ );
+
+ // 상품 존재 여부 확인
+ boolean existsByProductId(UUID productId);
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/service/ReservationDomainService.java b/src/main/java/org/pgsg/reservation/domain/service/ReservationDomainService.java
new file mode 100644
index 0000000..1ddf247
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/service/ReservationDomainService.java
@@ -0,0 +1,323 @@
+package org.pgsg.reservation.domain.service;
+
+import lombok.RequiredArgsConstructor;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.pgsg.reservation.domain.model.reservation.*;
+import org.pgsg.reservation.domain.model.reservationcandidate.ReservationCandidate;
+import org.pgsg.reservation.domain.model.reservationcandidate.ReservationCandidateStatus;
+import org.pgsg.reservation.domain.model.reservationhistory.ReservationHistory;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class ReservationDomainService {
+
+ private final ReservationValidator reservationValidator;
+
+ /**
+ * 예약 생성 로직
+ * 각 VO들을 조합하여 예약 엔티티를 생성하고, 도메인 규칙을 검증
+ */
+ public Reservation createReservation(SellerInfo seller, ProductInfo product ) {
+
+ // 검증 로직
+ reservationValidator.validate(seller, product);
+
+ // 엔티티 생성
+ return Reservation.create(null, seller, product);
+ }
+
+ /**
+ * 예약 목록 조회 정책 획득 로직
+ * 권한에 따른 조회 범위를 검증하고 정책(Policy)을 결정
+ */
+ public SearchPolicy getReservations(UUID userId, String role) {
+ reservationValidator.validateSearchRequest(userId, role);
+
+ String normalizedRole = normalizeRole(role);
+
+ // MASTER, MANAGER는 전체 조회
+ if (isAdminRole(normalizedRole)) {
+ return SearchPolicy.all();
+ }
+
+ // 그 외 모든 일반 사용자(USER 등)는 본인 것만 조회 (판매 + 구매)
+ return SearchPolicy.user(userId);
+ }
+
+ /**
+ * 상세 조회 권한 검증
+ * 특정 사용자가 해당 예약에 접근할 수 있는지 비즈니스 규칙 검사
+ */
+ public void validateDetailAccess(Reservation reservation, UUID userId, String role) {
+ SearchPolicy policy = this.getReservations(userId, role);
+
+ if (policy.isUserFilter()) {
+ // Objects.equals는 파라미터가 null이어도 에러를 내지 않고 false를 반환해서 안전합니다.
+ boolean isBuyer = reservation.getBuyerInfo() != null &&
+ Objects.equals(reservation.getBuyerInfo().getBuyerId(), userId);
+
+ boolean isSeller = reservation.getSellerInfo() != null &&
+ Objects.equals(reservation.getSellerInfo().getSellerId(), userId);
+
+ if (!isBuyer && !isSeller) {
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS, "해당 예약을 조회할 권한이 없습니다.");
+ }
+ }
+ }
+
+ /**
+ * 구매자 예약 신청
+ * 특정 사용자가 해당 예약에 접근할 수 있는지 비즈니스 규칙 검사
+ */
+ public ReservationCandidate addCandidate(Reservation reservation, UUID userId, String nickname) {
+ // 이미 후보자로 등록되어 있는지 확인
+ boolean isAlreadyApplied = reservation.getCandidates().stream()
+ .anyMatch(c -> c.getCandidateId().equals(userId));
+ if (isAlreadyApplied) {
+ throw new ReservationException(ReservationErrorCode.ALREADY_APPLIED);
+ }
+
+ // 예약이 활성 상태(AVAILABLE)인지 확인
+ if (reservation.getStatus() != ReservationStatus.AVAILABLE) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+
+ // 현재 구매자가 없는 상태(reopen된 상태)라면 내가 바로 구매자가 변경
+ boolean shouldBeSelectedImmediately = (reservation.getBuyerInfo() == null);
+
+ // 후보자 생성 및 애그리거트에 추가
+ ReservationCandidate candidate = ReservationCandidate.create(reservation, userId, nickname);
+
+ reservation.addCandidate(candidate);
+
+ // 만약 첫 번째 후보자라면 바로 구매자로 선정하는 로직
+ if (shouldBeSelectedImmediately) {
+ reservation.changeToNextBuyer(candidate);
+ }
+
+ return candidate;
+ }
+
+ /**
+ * 구매자 사유 취소 도메인 로직
+ * 구매자 혹은 관리자가 호출하며, 취소 후 다음 대기자에게 예약 권한을 승계함
+ */
+ public ReservationHistory cancelByBuyer(Reservation reservation, UUID userId, String role, String reason) {
+ validateReason(reason);
+
+ // 권한 및 상태 검증
+ reservationValidator.validateCancelByBuyer(reservation, userId, role);
+
+ ReservationStatus previousStatus = reservation.getStatus();
+
+ // 엔티티 상태 변경
+ reservation.cancelByBuyer();
+
+ // 다음 구매자 승계 처리
+ handleNextBuyerSequence(reservation);
+
+ // 이력 객체 생성
+ return ReservationHistory.of(
+ reservation.getId(),
+ previousStatus,
+ reservation.getStatus(),
+ reason,
+ userId
+ );
+ }
+
+ /**
+ * 결제 완료 로직
+ * 관리자가 호출 or 결제 서비스에서 이벤트를 받을 경우, 상품 상태를 (PENDING -> PAID)로 변경
+ */
+ public ReservationHistory confirmPayment(Reservation reservation, UUID userId, String role) {
+
+ // 권한 검증 (관리자 혹은 시스템 권한)
+ reservationValidator.validateConfirmPayment(reservation, userId, role);
+
+ ReservationStatus previousStatus = reservation.getStatus();
+
+ // 상태 전이 검증 (PENDING일 때만 결제 완료 가능)
+ reservation.markAsPaid();
+
+ return ReservationHistory.of(
+ reservation.getId(),
+ previousStatus,
+ reservation.getStatus(),
+ "결제 승인 완료: 상태가 PAID로 변경되었습니다.", // reason
+ userId
+ );
+ }
+
+ /**
+ * 판매자 사유 취소 도메인 로직
+ * 판매자 혹은 관리자가 호출하며, 취소 후 예약 비활성화 후 상품 삭제 요청
+ */
+ public ReservationHistory cancelBySeller(Reservation reservation, UUID userId, String role, String reason) {
+ validateReason(reason);
+
+ // 권한 및 상태 검증
+ reservationValidator.validateCancelBySeller(reservation, userId, role);
+
+ ReservationStatus previousStatus = reservation.getStatus();
+
+ // 엔티티 상태 변경
+ reservation.cancelBySeller();
+
+ // 이력 객체 생성
+ return ReservationHistory.of(
+ reservation.getId(),
+ previousStatus,
+ reservation.getStatus(),
+ reason,
+ userId
+ );
+ }
+
+ /**
+ * 예약 만료 로직
+ * 예약이 만료 될 시 작동 혹은 시스템 문제로 관리자 임의 실행
+ */
+ public ReservationHistory expireByAdmin(
+ Reservation reservation,
+ UUID adminId,
+ String role,
+ ReservationStatus targetStatus,
+ String reason
+ ) {
+ validateReason(reason);
+
+ // 관리자 권한 및 취소 가능 상태인지 확인
+ reservationValidator.validateSearchRequest(adminId, role);
+ if (!isAdminRole(normalizeRole(role))) {
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS);
+ }
+ reservationValidator.validateCommonCancel(reservation, adminId);
+
+ // 이력 기록을 위해 변경 전 상태 보관
+ ReservationStatus previousStatus = reservation.getStatus();
+
+ // 상태 변경
+ if (targetStatus == ReservationStatus.CANCELLED_BY_BUYER) {
+ // 구매자 사유 취소로 취급 -> 차순위 승계(handleNextBuyer) 로직 작동
+ reservation.cancelByBuyer();
+
+ // 여기서 직접 차순위 로직을 호출하거나, 이벤트를 발행
+ handleNextBuyerSequence(reservation);
+
+ } else if (targetStatus == ReservationStatus.CANCELLED_BY_SELLER) {
+ // 판매자 사유 취소로 취급 -> 승계 없이 최종 종료
+ reservation.cancelBySeller();
+ } else {
+ // 그 외 정의되지 않은 상태 변경 시도 시 예외
+ throw new ReservationException(ReservationErrorCode.INVALID_INPUT);
+ }
+
+ // 3. 결과물(History) 생성 및 반환
+ return ReservationHistory.of(
+ reservation.getId(),
+ previousStatus,
+ reservation.getStatus(),
+ reason,
+ adminId
+ );
+ }
+
+ /**
+ * 예약 완료
+ * 예약 완료 도메인 로직 및 이력 객체 생성
+ */
+ public ReservationHistory completeReservation(Reservation reservation, UUID userId ,String role) {
+ // 권한 검증 로직
+ boolean isSeller = reservation.getSellerInfo() != null && Objects.equals(reservation.getSellerInfo().getSellerId(), userId);
+ boolean isAdmin = isAdminRole(normalizeRole(role));
+ if (!isAdmin && !isSeller) {
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS);
+ }
+
+ ReservationStatus previousStatus = reservation.getStatus();
+
+ // 엔티티 비즈니스 로직 실행 (PAID -> COMPLETED)
+ // 규칙: 예약 대기 상태일 때만 완료 가능
+ reservation.complete();
+
+ // 저장할 이력 객체 생성 및 반환
+ return ReservationHistory.of(
+ reservation.getId(),
+ previousStatus,
+ ReservationStatus.COMPLETED,
+ "판매자 채팅 수락으로 인한 예약 완료",
+ userId
+ );
+ }
+
+ /**
+ * 거래 완료
+ * 거래 완료 도메인 로직 및 이력 객체 생성
+ */
+ public ReservationHistory confirmTrade(Reservation reservation, UUID userId, String role) {
+ // 규칙: 오직 ADMIN(시스템 포함)만 거래 확정을 할 수 있다.
+ if (!isAdminRole(normalizeRole(role))) {
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS, "관리자만 거래를 확정할 수 있습니다.");
+ }
+
+ // 상태 변경 전 상태 보관 (이력 기록용)
+ ReservationStatus previousStatus = reservation.getStatus();
+
+ // 엔티티 비즈니스 로직 실행 (COMPLETED -> CLOSED)
+ reservation.confirmTrade();
+
+ // 이력(History) 객체 생성 및 반환
+ return ReservationHistory.of(
+ reservation.getId(),
+ previousStatus,
+ reservation.getStatus(),
+ "거래가 최종 확정되었습니다.",
+ userId
+ );
+ }
+
+ private String normalizeRole(String role) {
+ if (role == null) return "";
+ String upperRole = role.trim().toUpperCase(Locale.ROOT);
+ if (upperRole.startsWith("ROLE_")) {
+ return upperRole.substring(5).trim();
+ }
+ return upperRole;
+ }
+
+ private boolean isAdminRole(String normalizedRole) {
+ return "ADMIN".equals(normalizedRole) || "MANAGER".equals(normalizedRole) || "MASTER".equals(normalizedRole);
+ }
+
+ /**
+ * 다음 구매자 승계 내부 로직
+ */
+ private void handleNextBuyerSequence(Reservation reservation) {
+ // WAITING 상태 중 생성일 오름차순 -> ID 오름차순
+ // 대기자 중 가장 우선 우선순위가 높은 사람을 찾음
+ Optional nextCandidate = reservation.getCandidates().stream()
+ .filter(c -> c.getStatus() == ReservationCandidateStatus.WAITING)
+ .min(Comparator.comparing(ReservationCandidate::getCreatedAt)
+ .thenComparing(ReservationCandidate::getId));
+
+ if (nextCandidate.isPresent()) {
+ // a. 대기자가 있으면 승계 처리
+ reservation.changeToNextBuyer(nextCandidate.get());
+ } else {
+ // b. 대기자가 없으면 다시 누구나 신청 가능한 상태로 복구
+ reservation.reopen();
+ }
+ }
+
+ private void validateReason(String reason) {
+ if (reason == null || reason.isBlank()) {
+ throw new ReservationException(ReservationErrorCode.INVALID_INPUT);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/domain/service/ReservationValidator.java b/src/main/java/org/pgsg/reservation/domain/service/ReservationValidator.java
new file mode 100644
index 0000000..00f9d23
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/domain/service/ReservationValidator.java
@@ -0,0 +1,105 @@
+package org.pgsg.reservation.domain.service;
+
+import org.pgsg.reservation.domain.model.reservation.*;
+import org.pgsg.reservation.domain.exception.*;
+import org.springframework.stereotype.Component;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.UUID;
+
+@Component
+public class ReservationValidator {
+
+ // 예약 생성
+ public void validate(SellerInfo seller, ProductInfo product) {
+ if (seller == null || product == null || seller.getSellerId() == null) {
+ throw new ReservationException(ReservationErrorCode.INVALID_INPUT);
+ }
+ }
+
+ public void validateSearchRequest(UUID userId, String role) {
+ if (userId == null || role == null || role.isBlank()) {
+ // 권한 정보가 부족할 때 던지는 예외
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS);
+ }
+ }
+
+ // 구매자,관리자 취소 권한 및 상태 검증
+ public void validateCancelByBuyer(Reservation reservation, UUID userId, String role) {
+ validateCommonCancel(reservation, userId);
+
+ boolean isBuyer = reservation.getBuyerInfo() != null &&
+ Objects.equals(reservation.getBuyerInfo().getBuyerId(), userId);
+
+ boolean isAdmin = isAdminRole(normalizeRole(role));
+
+ if (!isBuyer && !isAdmin) {
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS);
+ }
+ }
+
+ // 시스템,관리자 결제 완료 권한 및 상태 검증
+ public void validateConfirmPayment(Reservation reservation, UUID userId, String role) {
+ if (reservation == null || userId == null) {
+ throw new ReservationException(ReservationErrorCode.INVALID_INPUT);
+ }
+
+ // 권한 검증: 관리자,SYSTEM만 허용함
+ String normalizedRole = normalizeRole(role);
+ boolean isAdmin = isAdminRole(normalizedRole);
+ boolean isSystem = "SYSTEM".equalsIgnoreCase(normalizedRole);
+ if (!isAdmin && !isSystem) {
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS);
+ }
+
+ // 상태 검증: 오직 PENDING 상태에서만 결제 확인이 가능함
+ if (reservation.getStatus() != ReservationStatus.PENDING) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+ }
+
+ // 판매자,관리자 취소 권한 및 상태 검증
+ public void validateCancelBySeller(Reservation reservation, UUID userId, String role) {
+ validateCommonCancel(reservation, userId);
+
+ boolean isSeller = reservation.getSellerInfo() != null &&
+ Objects.equals(reservation.getSellerInfo().getSellerId(), userId);
+
+ boolean isAdmin = isAdminRole(normalizeRole(role));
+
+ if (!isSeller && !isAdmin) {
+ throw new ReservationException(ReservationErrorCode.UNAUTHORIZED_ACCESS);
+ }
+ }
+
+ // 공통 취소 가능 상태 검증
+ public void validateCommonCancel(Reservation reservation, UUID userId) {
+ if (reservation == null || userId == null) {
+ throw new ReservationException(ReservationErrorCode.INVALID_INPUT);
+ }
+
+ // 이미 종료된 상태이거나 취소 불가능한 상태인지 확인
+ if (reservation.getStatus() == null || !reservation.getStatus().isMutable()) {
+ throw new ReservationException(ReservationErrorCode.CANNOT_CHANGE_STATUS);
+ }
+ }
+
+ // 본인 상품 예약 금지 규칙
+ private boolean isSamePerson(BuyerInfo buyer, SellerInfo seller) {
+ return buyer.getBuyerId().equals(seller.getSellerId());
+ }
+
+ private String normalizeRole(String role) {
+ if (role == null) return "";
+ String upperRole = role.trim().toUpperCase(Locale.ROOT);
+ if (upperRole.startsWith("ROLE_")) {
+ return upperRole.substring(5).trim();
+ }
+ return upperRole;
+ }
+
+ private boolean isAdminRole(String normalizedRole) {
+ return "ADMIN".equals(normalizedRole) || "MANAGER".equals(normalizedRole) || "MASTER".equals(normalizedRole);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/config/OutboxConfig.java b/src/main/java/org/pgsg/reservation/infrastructure/config/OutboxConfig.java
new file mode 100644
index 0000000..15e86fd
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/config/OutboxConfig.java
@@ -0,0 +1,21 @@
+package org.pgsg.reservation.infrastructure.config;
+
+import org.pgsg.common.domain.OutboxRepository;
+import org.pgsg.common.event.OutboxService;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.kafka.core.KafkaTemplate;
+
+@Configuration
+public class OutboxConfig {
+
+ @Bean
+ @Primary
+ public OutboxService outboxService(
+ OutboxRepository outboxRepository,
+ KafkaTemplate kafkaTemplate
+ ) {
+ return new OutboxService(outboxRepository, kafkaTemplate);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/config/QuerydslConfig.java b/src/main/java/org/pgsg/reservation/infrastructure/config/QuerydslConfig.java
new file mode 100644
index 0000000..d92373a
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/config/QuerydslConfig.java
@@ -0,0 +1,19 @@
+package org.pgsg.reservation.infrastructure.config;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class QuerydslConfig {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(entityManager);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/config/RedisConfig.java b/src/main/java/org/pgsg/reservation/infrastructure/config/RedisConfig.java
new file mode 100644
index 0000000..c2839f3
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/config/RedisConfig.java
@@ -0,0 +1,116 @@
+package org.pgsg.reservation.infrastructure.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
+import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.geo.GeoModule;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.time.Duration;
+
+@Configuration
+@EnableCaching
+public class RedisConfig {
+
+ @Value("${spring.data.redis.host}")
+ private String host;
+
+ @Value("${spring.data.redis.port}")
+ private int port;
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ return new LettuceConnectionFactory(host, port);
+ }
+
+ @Bean(name = "redisObjectMapper")
+ public ObjectMapper redisObjectMapper() {
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ objectMapper.registerModule(new JavaTimeModule());
+ objectMapper.registerModule(new GeoModule());
+ objectMapper.registerModule(new ParameterNamesModule());
+
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+
+ objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+ objectMapper.setVisibility(PropertyAccessor.CREATOR, JsonAutoDetect.Visibility.ANY);
+ objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
+
+ objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
+
+ PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
+ .allowIfBaseType(Object.class)
+ .build();
+
+ objectMapper.activateDefaultTyping(
+ typeValidator,
+ ObjectMapper.DefaultTyping.EVERYTHING,
+ JsonTypeInfo.As.PROPERTY
+ );
+
+ return objectMapper;
+ }
+
+ @Bean
+ public RedisTemplate redisTemplate(
+ RedisConnectionFactory factory,
+ @Qualifier("redisObjectMapper") ObjectMapper redisObjectMapper) {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(factory);
+
+ GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisObjectMapper);
+
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setValueSerializer(jsonSerializer);
+ redisTemplate.setHashKeySerializer(new StringRedisSerializer());
+ redisTemplate.setHashValueSerializer(jsonSerializer);
+
+ return redisTemplate;
+ }
+
+ @Bean
+ public CacheManager cacheManager(
+ RedisConnectionFactory factory,
+ @Qualifier("redisObjectMapper") ObjectMapper redisObjectMapper) {
+
+ GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisObjectMapper);
+
+ RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
+ .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
+ .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
+ .entryTtl(Duration.ofDays(1));
+
+ RedisCacheConfiguration listCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
+ .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
+ .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
+ .entryTtl(Duration.ofSeconds(3));
+
+ return RedisCacheManager.builder(factory)
+ .cacheDefaults(defaultCacheConfig)
+ .withCacheConfiguration("reservations", listCacheConfig)
+ .withCacheConfiguration("reservationDetail", defaultCacheConfig)
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/config/RedissonConfig.java b/src/main/java/org/pgsg/reservation/infrastructure/config/RedissonConfig.java
new file mode 100644
index 0000000..b92a375
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/config/RedissonConfig.java
@@ -0,0 +1,28 @@
+package org.pgsg.reservation.infrastructure.config;
+
+import org.redisson.Redisson;
+import org.redisson.api.RedissonClient;
+import org.redisson.config.Config;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class RedissonConfig {
+
+ @Value("${spring.data.redis.host}")
+ private String host;
+
+ @Value("${spring.data.redis.port}")
+ private int port;
+
+ @Bean
+ public RedissonClient redissonClient() {
+ Config config = new Config();
+
+ config.useSingleServer()
+ .setAddress("redis://" + host + ":" + port);
+
+ return Redisson.create(config);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/config/ReservationSecurityConfig.java b/src/main/java/org/pgsg/reservation/infrastructure/config/ReservationSecurityConfig.java
new file mode 100644
index 0000000..d2f8f1a
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/config/ReservationSecurityConfig.java
@@ -0,0 +1,62 @@
+package org.pgsg.reservation.infrastructure.config;
+
+import lombok.RequiredArgsConstructor;
+import org.pgsg.config.security.CustomAccessDeniedHandler;
+import org.pgsg.config.security.CustomAuthenticationEntryPoint;
+import org.pgsg.config.security.LoginFilter;
+import org.pgsg.config.security.SecurityConfigImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity // @PreAuthorize 사용을 위해 필요
+@RequiredArgsConstructor
+@Import({SecurityConfigImpl.class})
+public class ReservationSecurityConfig {
+
+ private final LoginFilter loginFilter;
+ private final CustomAuthenticationEntryPoint authenticationEntryPoint;
+ private final CustomAccessDeniedHandler accessDeniedHandler;
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+
+ .authorizeHttpRequests(auth -> auth
+ // 공용 API 및 모니터링 허용
+ .requestMatchers(
+ "/v3/api-docs/**",
+ "/swagger-ui/**",
+ "/actuator/**",
+ "/favicon.ico",
+ "/error"
+ ).permitAll()
+
+ // 나머지는 인증 필요
+ .anyRequest().authenticated()
+ )
+
+ // LoginFilter를 인증 필터 앞에 배치
+ .addFilterBefore(loginFilter, UsernamePasswordAuthenticationFilter.class)
+
+ // 커스텀 예외 처리
+ .exceptionHandling(c -> {
+ c.authenticationEntryPoint(authenticationEntryPoint);
+ c.accessDeniedHandler(accessDeniedHandler);
+ });
+
+ return http.build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/event/ReservationEventBridge.java b/src/main/java/org/pgsg/reservation/infrastructure/event/ReservationEventBridge.java
new file mode 100644
index 0000000..1445daf
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/event/ReservationEventBridge.java
@@ -0,0 +1,143 @@
+package org.pgsg.reservation.infrastructure.event;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.pgsg.common.event.Events;
+import org.pgsg.common.event.OutboxEvent;
+import org.pgsg.reservation.application.dto.event.ReservationCancelledEvent;
+import org.pgsg.reservation.application.dto.event.ReservationCompletedEvent;
+import org.pgsg.reservation.application.dto.event.ReservationFailedEvent;
+import org.pgsg.reservation.infrastructure.listener.dto.ReservationEventPublisher;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class ReservationEventBridge implements ReservationEventPublisher {
+
+ @Value("${topics.reservation.completed}")
+ private String completedTopicName;
+
+ @Value("${topics.reservation.cancelled}")
+ private String buyerCancelledTopicName;
+
+ @Value("${topics.product.failed}")
+ private String productFailureTopicName;
+
+ @Value("${topics.reservation.failed}")
+ private String tradeFailureTopicName;
+
+ @Override
+ public void publishReservationCompleted(Reservation reservation) {
+ try {
+ log.info("예약 완료 Outbox 등록 시작 - 예약 ID: {}", reservation.getId());
+
+ // Product 서비스에서 식별자로 쓸 ID (상품 ID)
+ UUID correlationId = UUID.fromString(reservation.getProductInfo().getProductId().toString());
+
+ // Outbox 테이블의 PK로 저장될 ID (예약 ID)
+ UUID domainId = UUID.fromString(reservation.getId().toString());
+
+ // 페이로드 준비
+ ReservationCompletedEvent event = ReservationCompletedEvent.from(reservation);
+
+ Events.trigger(new OutboxEvent(
+ correlationId, // correlationId
+ domainId, // domainId (UUID 타입)
+ "RESERVATION", // domainType
+ completedTopicName,// eventType (토픽명)
+ event // payload
+ ));
+
+ log.info("예약 완료 Outbox 등록 완료 - correlationId: {}", correlationId);
+
+ } catch (Exception e) {
+ log.error("Outbox 등록 실패 - 예약 ID: {}", reservation.getId(), e);
+ throw new RuntimeException("이벤트 발행 중 오류 발생", e);
+ }
+ }
+
+ // 상품 생성 실패시 상품에 이벤트 발송
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void publishReservationCreationFailed(UUID productId, String reason) {
+ try {
+ log.info("예약 생성 실패 Outbox 등록 시작 - 상품 ID: {}", productId);
+
+ // 엔티티가 없으므로 correlationId와 domainId 모두 productId를 활용하거나,
+ // 별도의 랜덤 UUID를 domainId로 생성합니다.
+ UUID correlationId = UUID.fromString(productId.toString());
+ UUID domainId = UUID.randomUUID(); // Outbox PK용
+
+ ReservationFailedEvent event = new ReservationFailedEvent(productId, reason);
+
+ Events.trigger(new OutboxEvent(
+ correlationId, // 상품 서비스가 식별할 ID
+ domainId, // Outbox 식별 ID
+ "RESERVATION",
+ productFailureTopicName, // 실패 전용 토픽
+ event
+ ));
+
+ log.info("예약 생성 실패 Outbox 등록 완료 - productId: {}", productId);
+
+ } catch (Exception e) {
+ log.error("실패 알림 Outbox 등록 중 오류 발생 - 상품 ID: {}", productId, e);
+ throw new RuntimeException("예약 생성 실패 이벤트 발행 중 오류 발생", e);
+ }
+ }
+
+ @Override
+ public void publishReservationCancelled(Reservation reservation, String reason) {
+ try {
+ log.info("판매자 사유로 인한 취소 Outbox 등록 시작 - 예약 ID: {}", reservation.getId());
+
+ // Product/Trade 서비스에서 식별자로 쓸 ID (상품 ID)
+ UUID correlationId = UUID.fromString(reservation.getProductInfo().getProductId().toString());
+
+ // Outbox 테이블의 PK로 저장될 ID (예약 ID)
+ UUID domainId = UUID.fromString(reservation.getId().toString());
+
+ ReservationCancelledEvent event = ReservationCancelledEvent.from(reservation, reason);
+
+ Events.trigger(new OutboxEvent(
+ correlationId, // 연관 ID (상품 ID)
+ domainId, // 도메인 ID (예약 ID)
+ "RESERVATION", // 도메인 타입
+ buyerCancelledTopicName, // 이벤트 타입 (취소 토픽명)
+ event // 페이로드
+ ));
+
+ log.info("판매자 사유로 인한 취소 Outbox 등록 완료 - correlationId: {}", correlationId);
+
+ } catch (Exception e) {
+ log.error("취소 Outbox 등록 실패 - 예약 ID: {}", reservation.getId(), e);
+ throw new RuntimeException("취소 이벤트 발행 중 오류 발생", e);
+ }
+ }
+
+ // 거래 완료 생성 실패시 발송하는 이벤트
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void publishTradeConfirmFailed(UUID reservationId, String reason) {
+ log.info("예약 생성 실패 Outbox 등록 시작 - 예약 ID: {}", reservationId);
+
+ UUID correlationId = UUID.fromString(reservationId.toString());
+ UUID domainId = UUID.randomUUID();
+
+ // 실패 전용 DTO: ReservationFailedEvent
+ ReservationFailedEvent event = new ReservationFailedEvent(correlationId, reason);
+
+ Events.trigger(new OutboxEvent(
+ correlationId,
+ domainId,
+ "TRADE", // 도메인 타입 구분
+ tradeFailureTopicName, // 거래 서비스가 구독할 토픽명
+ event
+ ));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/listener/ProductListener.java b/src/main/java/org/pgsg/reservation/infrastructure/listener/ProductListener.java
new file mode 100644
index 0000000..aee5ac4
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/listener/ProductListener.java
@@ -0,0 +1,64 @@
+package org.pgsg.reservation.infrastructure.listener;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.pgsg.common.messaging.annotation.IdempotentConsumer;
+import org.pgsg.reservation.application.dto.command.ReservationCreateCommand;
+import org.pgsg.reservation.infrastructure.listener.dto.TimeDealProductEvent;
+import org.pgsg.reservation.application.service.ReservationService;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+import org.pgsg.reservation.infrastructure.event.ReservationEventBridge;
+
+import java.util.Objects;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductListener {
+
+ private final ReservationService reservationService;
+ private final ObjectMapper objectMapper;
+ private final ReservationEventBridge eventBridge;
+
+ /**
+ * 상품 서비스에서 타임딜 상품이 생성되었을 때의 이벤트를 수신합니다.
+ */
+ @IdempotentConsumer("product-completed:product-service")
+ @KafkaListener(topics = "prod-product-created",groupId = "product-group")
+ public void handleTimeDealEvent(ConsumerRecord record) {
+ TimeDealProductEvent event = null;
+
+ try {
+ event = objectMapper.readValue(record.value(), TimeDealProductEvent.class);
+ log.info("타임딜 상품 생성 이벤트 수신 - Product ID: {}, Name: {}",
+ event.productId(), event.name());
+
+ ReservationCreateCommand command = new ReservationCreateCommand(
+ event.productId(), event.sellerId(), event.sellerName(),
+ event.name(), event.price(), event.endTime()
+ );
+
+ reservationService.createReservation(command);
+ }catch (ReservationException e) {
+ if (e.getErrorCode() == ReservationErrorCode.ALREADY_EXISTS) {
+ log.info("중복 예약 생성 이벤트 무시 (이미 존재): productId={}", Objects.requireNonNull(event).productId());
+ return;
+ }
+
+ log.error("예약 생성 비즈니스 실패: {}", e.getMessage());
+ eventBridge.publishReservationCreationFailed(Objects.requireNonNull(event).productId(), e.getMessage());
+
+ } catch (Exception e) {
+ log.error("시스템 장애로 인한 예약 생성 실패: {}", e.getMessage());
+ if (event != null) {
+ eventBridge.publishReservationCreationFailed(event.productId(), "SYSTEM_ERROR: " + e.getMessage());
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/listener/ReservationEventListener.java b/src/main/java/org/pgsg/reservation/infrastructure/listener/ReservationEventListener.java
new file mode 100644
index 0000000..c39682d
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/listener/ReservationEventListener.java
@@ -0,0 +1,80 @@
+package org.pgsg.reservation.infrastructure.listener;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.pgsg.common.messaging.annotation.IdempotentConsumer;
+import org.pgsg.reservation.application.dto.command.ReservationConfirmCommand;
+import org.pgsg.reservation.application.service.ReservationService;
+import org.pgsg.reservation.infrastructure.listener.dto.TradeCompletedEvent;
+import org.pgsg.reservation.infrastructure.event.ReservationEventBridge;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ReservationEventListener {
+
+ private final ReservationService reservationService;
+ private final ObjectMapper objectMapper;
+ private final ReservationEventBridge bridge;
+
+ private static final UUID SYSTEM_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+ private static final String SYSTEM_ROLE = "ADMIN";
+
+ /**
+ * 거래 서비스로부터 거래 완료 이벤트를 수신
+ */
+ @IdempotentConsumer("reservation-completed:trade-service")
+ @KafkaListener(
+ topics = "prod-trade-completed",
+ groupId = "reservation-group"
+ )
+ public void handleTradeCompleted(ConsumerRecord record) {
+ TradeCompletedEvent event;
+ UUID reservationId = null;
+ try {
+ JsonNode root = objectMapper.readTree(record.value());
+ try {
+ reservationId = UUID.fromString(root.get("reservationId").asText());
+ } catch (IllegalArgumentException e) {
+ log.warn("유효하지 않은 UUID 형식의 reservationId 수신: {}", root.get("reservationId").asText());
+ }
+ event = objectMapper.treeToValue(root, TradeCompletedEvent.class);
+
+ log.info("거래 완료 이벤트 수신 - Trade ID: {}, Reservation ID: {}",
+ event.tradeId(), event.reservationId());
+
+ ReservationConfirmCommand command = new ReservationConfirmCommand(
+ event.reservationId(),
+ SYSTEM_ID,
+ SYSTEM_ROLE
+ );
+
+ reservationService.confirmTrade(command);
+
+ log.info("예약 확정 처리 성공 - Reservation ID: {}", event.reservationId());
+ }catch (Exception e) {
+ log.error("예약 확정 처리 실패 - Reservation ID: {}, Error: {}",
+ (reservationId != null) ? reservationId : "Unknown", e.getMessage());
+
+ // 실패 알림 발송
+ // 이 알림을 보고 거래 서비스나 관리자 서비스가 후속 조치(거래 롤백 등)를 할 수 있습니다.
+ if (reservationId != null) {
+ // 사실 여기서는 productId를 알기 어려울 수 있으니,
+ // 브릿지에 reservationId를 받는 실패 메서드를 하나 더 만들거나
+ // event에서 productId를 꺼낼 수 있도록 설계하는 것이 좋을 수도?
+ bridge.publishTradeConfirmFailed(
+ reservationId, // UUID 타입
+ "CONFIRM_FAIL: " + e.getMessage()
+ );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/ReservationEventPublisher.java b/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/ReservationEventPublisher.java
new file mode 100644
index 0000000..510a1eb
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/ReservationEventPublisher.java
@@ -0,0 +1,9 @@
+package org.pgsg.reservation.infrastructure.listener.dto;
+
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+
+public interface ReservationEventPublisher {
+ void publishReservationCompleted(Reservation reservation);
+
+ void publishReservationCancelled(Reservation reservation, String reason);
+}
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/TimeDealProductEvent.java b/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/TimeDealProductEvent.java
new file mode 100644
index 0000000..f9a5d7b
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/TimeDealProductEvent.java
@@ -0,0 +1,22 @@
+package org.pgsg.reservation.infrastructure.listener.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty; // 추가됨
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record TimeDealProductEvent(
+ UUID productId,
+
+ String name,
+
+ Integer price,
+
+ @JsonFormat(shape = JsonFormat.Shape.ARRAY)
+ LocalDateTime endTime,
+
+ UUID sellerId,
+
+ @JsonProperty("sellerNickName")
+ String sellerName
+) {}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/TradeCompletedEvent.java b/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/TradeCompletedEvent.java
new file mode 100644
index 0000000..dcb6d43
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/listener/dto/TradeCompletedEvent.java
@@ -0,0 +1,9 @@
+package org.pgsg.reservation.infrastructure.listener.dto;
+
+import java.util.UUID;
+
+public record TradeCompletedEvent(
+ UUID tradeId,
+ UUID reservationId,
+ UUID productId
+) {}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/repository/MyReservationOutboxRepository.java b/src/main/java/org/pgsg/reservation/infrastructure/repository/MyReservationOutboxRepository.java
new file mode 100644
index 0000000..42c017a
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/repository/MyReservationOutboxRepository.java
@@ -0,0 +1,12 @@
+package org.pgsg.reservation.infrastructure.repository;
+
+import org.pgsg.common.domain.Outbox;
+import org.pgsg.common.domain.OutboxRepository; // 중요: 공통 모듈의 인터페이스
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+import java.util.UUID;
+
+@Repository
+public interface MyReservationOutboxRepository
+ extends JpaRepository, OutboxRepository {
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/repository/reservation/JpaReservationRepository.java b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservation/JpaReservationRepository.java
new file mode 100644
index 0000000..8998a56
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservation/JpaReservationRepository.java
@@ -0,0 +1,10 @@
+package org.pgsg.reservation.infrastructure.repository.reservation;
+
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.UUID;
+
+public interface JpaReservationRepository extends JpaRepository {
+ boolean existsByProductInfoProductId(UUID productId);
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/repository/reservation/ReservationRepositoryImpl.java b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservation/ReservationRepositoryImpl.java
new file mode 100644
index 0000000..ac81354
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservation/ReservationRepositoryImpl.java
@@ -0,0 +1,164 @@
+package org.pgsg.reservation.infrastructure.repository.reservation;
+
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.PathBuilder;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.pgsg.reservation.domain.dto.ReservationSearchCriteria;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.pgsg.reservation.domain.model.reservation.SearchPolicy;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import org.pgsg.reservation.domain.repository.ReservationRepository;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+@Repository
+@RequiredArgsConstructor
+public class ReservationRepositoryImpl implements ReservationRepository {
+
+ private final JpaReservationRepository reservationJpaRepository;
+ private final JPAQueryFactory queryFactory;
+
+ private final PathBuilder reservation =
+ new PathBuilder<>(Reservation.class, "reservation");
+
+ @Override
+ public Reservation save(Reservation reservationEntity) {
+ return reservationJpaRepository.save(reservationEntity);
+ }
+
+ @Override
+ public boolean existsByProductId(UUID productId) {
+ return reservationJpaRepository.existsByProductInfoProductId(productId);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List findAllByStatusInAndModifiedAtBeforeOrProductInfoEndTimeBefore(
+ List statuses,
+ LocalDateTime threshold,
+ LocalDateTime now
+ ){
+ if (statuses == null || statuses.isEmpty() || threshold == null || now == null) {
+ return List.of();
+ }
+
+ // 조건 1: 상태가 AVAILABLE 이면서 endTime이 지난 경우
+ BooleanExpression availableExpired = statusEq(ReservationStatus.AVAILABLE)
+ .and(endTimeBefore(now));
+
+ // 조건 2: 상태가 PENDING/PAID 이면서 (1시간이 경과했거나 OR endTime이 지난 경우)
+ BooleanExpression activeStatuses = reservation.get("status", ReservationStatus.class)
+ .in(ReservationStatus.PENDING, ReservationStatus.PAID);
+
+ BooleanExpression pendingOrPaidExpired = activeStatuses
+ .and(modifiedAtBefore(threshold).or(endTimeBefore(now)));
+
+ // 두 조건을 OR로 묶어서 조회합니다.
+ return queryFactory
+ .selectFrom(reservation)
+ .where(availableExpired.or(pendingOrPaidExpired))
+ .fetch();
+ }
+
+ @Override
+ public Optional findById(UUID id) {
+ return reservationJpaRepository.findById(id);
+ }
+
+ @Override
+ public Page findByCriteria(
+ ReservationSearchCriteria criteria,
+ Pageable pageable
+ ) {
+ // 검색 조건 공통화
+ BooleanExpression[] predicates = {
+ applyPolicyFilter(criteria.policy()),
+ statusEq(criteria.status()),
+ productNameContains(criteria.productName()),
+ productIdEq(criteria.productId()),
+ sellerNameEq(criteria.sellerName()),
+ buyerNameEq(criteria.buyerName())
+ };
+
+ List content = queryFactory
+ .selectFrom(reservation)
+ .where(predicates)
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .orderBy(reservation.getDateTime("createdAt", java.time.LocalDateTime.class).desc())
+ .fetch();
+
+ Long total = queryFactory
+ .select(reservation.count())
+ .from(reservation)
+ .where(predicates) // 정리된 조건 적용
+ .fetchOne();
+
+ return new PageImpl<>(content, pageable, total != null ? total : 0L);
+ }
+
+ private BooleanExpression applyPolicyFilter(SearchPolicy policy) {
+ if (policy == null || !policy.isUserFilter()) return null;
+
+ return reservation.get("buyerInfo").get("buyerId", UUID.class).eq(policy.accessUserId())
+ .or(reservation.get("sellerInfo").get("sellerId", UUID.class).eq(policy.accessUserId()));
+ }
+
+ private BooleanExpression statusEq(ReservationStatus status) {
+ return status != null ? reservation.get("status", ReservationStatus.class).eq(status) : null;
+ }
+
+ private BooleanExpression productNameContains(String productName) {
+ String normalized = productName == null ? null : productName.trim();
+ return (normalized != null && !normalized.isBlank())
+ ? reservation.get("productInfo").getString("productName").containsIgnoreCase(normalized)
+ : null;
+ }
+
+ private BooleanExpression productIdEq(UUID productId) {
+ return productId != null
+ ? reservation.get("productInfo").get("productId", UUID.class).eq(productId)
+ : null;
+ }
+
+ private BooleanExpression sellerNameEq(String sellerName) {
+ String normalized = sellerName == null ? null : sellerName.trim();
+ return (normalized != null && !normalized.isBlank())
+ ? reservation.get("sellerInfo").getString("sellerName").containsIgnoreCase(normalized)
+ : null;
+ }
+
+ private BooleanExpression buyerNameEq(String buyerName) {
+ String normalized = buyerName == null ? null : buyerName.trim();
+ return (normalized != null && !normalized.isBlank())
+ ? reservation.get("buyerInfo").getString("buyerName").containsIgnoreCase(normalized)
+ : null;
+ }
+
+ private BooleanExpression statusIn(List statuses) {
+ return statuses != null && !statuses.isEmpty()
+ ? reservation.get("status", ReservationStatus.class).in(statuses)
+ : null;
+ }
+
+ private BooleanExpression modifiedAtBefore(LocalDateTime threshold) {
+ return threshold != null
+ ? reservation.getDateTime("modifiedAt", LocalDateTime.class).before(threshold)
+ : null;
+ }
+
+ private BooleanExpression endTimeBefore(LocalDateTime now) {
+ return now != null
+ ? reservation.get("productInfo").getDateTime("endTime", LocalDateTime.class).before(now)
+ : null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/repository/reservationhistory/JpaReservationHistoryRepository.java b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservationhistory/JpaReservationHistoryRepository.java
new file mode 100644
index 0000000..4520d2e
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservationhistory/JpaReservationHistoryRepository.java
@@ -0,0 +1,8 @@
+package org.pgsg.reservation.infrastructure.repository.reservationhistory;
+
+import org.pgsg.reservation.domain.model.reservationhistory.ReservationHistory;
+import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.UUID;
+
+public interface JpaReservationHistoryRepository extends JpaRepository {
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/repository/reservationhistory/ReservationHistoryRepositoryImpl.java b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservationhistory/ReservationHistoryRepositoryImpl.java
new file mode 100644
index 0000000..3b720dd
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/repository/reservationhistory/ReservationHistoryRepositoryImpl.java
@@ -0,0 +1,18 @@
+package org.pgsg.reservation.infrastructure.repository.reservationhistory;
+
+import lombok.RequiredArgsConstructor;
+import org.pgsg.reservation.domain.model.reservationhistory.ReservationHistory;
+import org.pgsg.reservation.domain.repository.ReservationHistoryRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class ReservationHistoryRepositoryImpl implements ReservationHistoryRepository {
+
+ private final JpaReservationHistoryRepository jpaRepository;
+
+ @Override
+ public ReservationHistory save(ReservationHistory history) {
+ return jpaRepository.save(history);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/infrastructure/scheduler/ReservationExpiryScheduler.java b/src/main/java/org/pgsg/reservation/infrastructure/scheduler/ReservationExpiryScheduler.java
new file mode 100644
index 0000000..ea52d6c
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/infrastructure/scheduler/ReservationExpiryScheduler.java
@@ -0,0 +1,95 @@
+package org.pgsg.reservation.infrastructure.scheduler;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.pgsg.reservation.application.dto.command.ReservationExpireCommand;
+import org.pgsg.reservation.application.service.ReservationService;
+import org.pgsg.reservation.domain.model.reservation.Reservation;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import org.pgsg.reservation.domain.repository.ReservationRepository;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ReservationExpiryScheduler {
+
+ private final ReservationService reservationService;
+ private final ReservationRepository reservationRepository;
+
+ // 시스템 자동 처리를 위한 가상의 관리자 정보
+ private static final UUID SYSTEM_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+ private static final String SYSTEM_ROLE = "ADMIN";
+
+ /**
+ * 매 1분마다 만료된 예약을 체크하여 자동 취소 처리
+ */
+ @Scheduled(cron = "0 * * * * *")
+ public void autoExpireReservations() {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime threshold = now.minusMinutes(60); // 1시간 경과 기준
+ List targetStatuses = List.of(ReservationStatus.AVAILABLE,ReservationStatus.PENDING, ReservationStatus.PAID);
+
+ // 1시간 경과했거나,endTime이 지났거나 둘 중 하나라도 해당되는 데이터 조회
+ List expiredReservations = reservationRepository.findAllByStatusInAndModifiedAtBeforeOrProductInfoEndTimeBefore(
+ targetStatuses,
+ threshold,
+ now
+ );
+
+ if (expiredReservations.isEmpty()) return;
+
+ log.info("시스템 자동 만료 대상 발견: {}건", expiredReservations.size());
+
+ for (Reservation reservation : expiredReservations) {
+ try {
+ // 종료 사유를 판단하여 커맨드 생성
+ ReservationExpireCommand command = createExpireCommand(reservation, now);
+ reservationService.expireByAdmin(command);
+
+ log.info("예약 자동 종료 완료 (ID: {}, 종료사유: {})", reservation.getId(), command.reason());
+ } catch (Exception e) {
+ log.error("예약 자동 만료 처리 중 오류 발생 (ID: {}): {}", reservation.getId(), e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * 예약 상태에 따른 적절한 취소 요청 객체를 생성하는 메서드
+ */
+ private ReservationExpireCommand createExpireCommand(Reservation reservation, LocalDateTime now) {
+ ReservationStatus targetStatus;
+ String reason;
+
+ // endTime이 먼저 지났는지 확인
+ boolean isEndTimeExpired = reservation.getProductInfo().getEndTime().isBefore(now);
+
+ if (isEndTimeExpired) {
+ targetStatus = (reservation.getStatus() == ReservationStatus.PENDING)
+ ? ReservationStatus.CANCELLED_BY_BUYER : ReservationStatus.CANCELLED_BY_SELLER;
+ reason = "타임딜 종료 시간 도달로 인한 자동 종료";
+ } else {
+ // 1시간 경과로 인한 종료인 경우
+ if (reservation.getStatus() == ReservationStatus.PENDING) {
+ targetStatus = ReservationStatus.CANCELLED_BY_BUYER;
+ reason = "결제 제한 시간(1시간) 초과로 인한 자동 취소";
+ } else {
+ targetStatus = ReservationStatus.CANCELLED_BY_SELLER;
+ reason = "채팅 수락 제한 시간(1시간) 초과로 인한 자동 취소";
+ }
+ }
+
+ return new ReservationExpireCommand(
+ reservation.getId(),
+ SYSTEM_ID,
+ SYSTEM_ROLE,
+ targetStatus,
+ reason
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/controller/ReservationController.java b/src/main/java/org/pgsg/reservation/presentation/controller/ReservationController.java
new file mode 100644
index 0000000..b86c53a
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/controller/ReservationController.java
@@ -0,0 +1,218 @@
+package org.pgsg.reservation.presentation.controller;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.pgsg.reservation.application.dto.command.*;
+import org.pgsg.reservation.application.dto.info.ReservationCandidateInfo;
+import org.pgsg.reservation.application.dto.info.ReservationStateInfo;
+import org.pgsg.reservation.application.dto.query.ReservationSearchQuery;
+import org.pgsg.reservation.application.dto.result.ReservationCreateResult;
+import org.pgsg.reservation.application.dto.result.ReservationDetailResult;
+import org.pgsg.reservation.application.service.ReservationService;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import org.pgsg.reservation.presentation.dto.request.ReservationAdminCancelRequest;
+import org.pgsg.reservation.presentation.dto.request.ReservationCancelRequest;
+import org.pgsg.reservation.presentation.dto.request.ReservationCreateRequest;
+import org.pgsg.reservation.presentation.dto.request.ReservationSearchRequest;
+import org.pgsg.reservation.presentation.dto.response.*;
+import org.pgsg.reservation.presentation.facade.ReservationFacade;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.UUID;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/reservations")
+public class ReservationController {
+
+ private final ReservationService reservationService;
+ private final ReservationFacade reservationFacade;
+
+ // 예약 생성
+ @PostMapping
+ public ReservationResponse createReservation(
+ @Valid @RequestBody ReservationCreateRequest request
+ ) {
+ ReservationCreateCommand command = new ReservationCreateCommand(
+ request.getProductId(), request.getSellerId(), request.getSellerNickname(),
+ request.getProductName(), request.getPrice(), request.getEndTime()
+ );
+
+ ReservationCreateResult result = reservationService.createReservation(command);
+
+ return ReservationResponse.from(result);
+ }
+
+ // 예약 목록 조회
+ @GetMapping
+ public ReservationPageResponse getReservations(
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role,
+ @ModelAttribute ReservationSearchRequest request,
+ @PageableDefault(size = 10) Pageable pageable
+ ) {
+
+ ReservationSearchQuery query = new ReservationSearchQuery(
+ request.getSellerName(),
+ request.getBuyerName(),
+ request.getProductName(),
+ request.getStatus(),
+ request.getProductId()
+ );
+
+ return ReservationPageResponse.from(
+ reservationService.getSearchReservations(userId, role, query, pageable)
+ .map(ReservationResponse::from)
+ );
+ }
+
+ // 예약 상세 목록 조회
+ @GetMapping("/{reservationId}")
+ public ReservationDetailResponse getReservationDetail(
+ @PathVariable UUID reservationId,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role
+ ) {
+
+ ReservationDetailResult result = reservationService.getReservationDetail(reservationId, userId, role);
+
+ return ReservationDetailResponse.from(result);
+ }
+
+ // 예약 신청
+ @PatchMapping("/{reservationId}")
+ public ReservationCandidateResponse applyReservation(
+ @PathVariable UUID reservationId,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Nickname") String nickname
+ ) {
+ ReservationApplyCommand command = ReservationApplyCommand.of(reservationId, userId, nickname);
+
+ ReservationCandidateInfo info = reservationFacade.applyReservation(command);
+
+ return ReservationCandidateResponse.of(info, "예약 신청이 성공적으로 완료되었습니다.");
+ }
+
+ // 구매자 사유 취소 (구매자/관리자)
+ @PatchMapping("/{reservationId}/cancel/buyer")
+ public ReservationStateResponse cancelByBuyer(
+ @PathVariable UUID reservationId,
+ @RequestBody ReservationCancelRequest request,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role
+ ) {
+ ReservationCancelCommand command = ReservationCancelCommand.of(
+ reservationId,
+ userId,
+ role,
+ request.reason()
+ );
+
+ ReservationStateInfo info = reservationService.cancelByBuyer(command);
+
+ return ReservationStateResponse.of(info, "구매자 사유 취소가 완료되었습니다.");
+ }
+
+ // 결제 완료
+ @PatchMapping("/{reservationId}/paymentconfirm")
+ public ReservationStateResponse confirmPayment(
+ @PathVariable UUID reservationId,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role
+ ) {
+ ReservationConfirmCommand command = ReservationConfirmCommand.of(
+ reservationId,
+ userId,
+ role
+ );
+
+ ReservationStateInfo info = reservationService.confirmPayment(command);
+
+ return ReservationStateResponse.of(info, "구매자의 결제 완료에 따라 예약 완료 처리가 완료되었습니다.");
+ }
+
+ // 판매자 사유로 인한 취소(판매자,관리자)
+ @PatchMapping("/{reservationId}/cancel/seller")
+ public ReservationStateResponse cancelBySeller(
+ @PathVariable UUID reservationId,
+ @RequestBody ReservationCancelRequest request,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role
+ ) {
+ ReservationCancelCommand command = ReservationCancelCommand.of(
+ reservationId,
+ userId,
+ role,
+ request.reason()
+ );
+
+ ReservationStateInfo info = reservationService.cancelBySeller(command);
+
+ return ReservationStateResponse.of(info, "판매자 사유 취소가 완료되었습니다.");
+ }
+
+ // 예약 만료(관리자만 조정 가능)
+ @PatchMapping("/{reservationId}/expire")
+ public ReservationStateResponse expireByAdmin(
+ @PathVariable UUID reservationId,
+ @RequestBody ReservationAdminCancelRequest request,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role
+ ) {
+ ReservationExpireCommand command = ReservationExpireCommand.of(
+ reservationId,
+ userId,
+ role,
+ request
+ );
+
+ ReservationStateInfo info = reservationService.expireByAdmin(command);
+
+ String message = (request.targetStatus() == ReservationStatus.CANCELLED_BY_BUYER)
+ ? "관리자 권한으로 구매자 사유 취소(승계) 처리가 완료되었습니다."
+ : "관리자 권한으로 판매자 사유 취소(종료) 처리가 완료되었습니다.";
+
+ return ReservationStateResponse.of(info, message);
+ }
+
+ // 예약 완료
+ @PatchMapping("/{reservationId}/complete")
+ public ReservationStateResponse completeReservation(
+ @PathVariable UUID reservationId,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role
+ ) {
+
+ ReservationConfirmCommand command = ReservationConfirmCommand.of(
+ reservationId,
+ userId,
+ role
+ );
+
+ ReservationStateInfo info = reservationService.completeReservation(command);
+
+ return ReservationStateResponse.of(info, "판매자 채팅 수락에 따라 예약 완료 처리가 완료되었습니다.");
+ }
+
+ // 거래 완료
+ @PatchMapping("/{reservationId}/tradeconfirm")
+ public ReservationStateResponse confirmTrade(
+ @PathVariable UUID reservationId,
+ @RequestHeader("X-User-Id") UUID userId,
+ @RequestHeader("X-User-Roles") String role
+ ) {
+ ReservationConfirmCommand command = ReservationConfirmCommand.of(
+ reservationId,
+ userId,
+ role
+ );
+
+ ReservationStateInfo info = reservationService.confirmTrade(command);
+
+ return ReservationStateResponse.of(info, "거래가 성공적으로 완료되어 확정 처리되었습니다.");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationAdminCancelRequest.java b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationAdminCancelRequest.java
new file mode 100644
index 0000000..184fff8
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationAdminCancelRequest.java
@@ -0,0 +1,8 @@
+package org.pgsg.reservation.presentation.dto.request;
+
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+
+public record ReservationAdminCancelRequest(
+ ReservationStatus targetStatus,
+ String reason
+) {}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationCancelRequest.java b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationCancelRequest.java
new file mode 100644
index 0000000..eef6408
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationCancelRequest.java
@@ -0,0 +1,8 @@
+package org.pgsg.reservation.presentation.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record ReservationCancelRequest(
+ @NotBlank(message = "취소 사유를 입력해주세요.")
+ String reason
+) {}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationCreateRequest.java b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationCreateRequest.java
new file mode 100644
index 0000000..ea19d16
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationCreateRequest.java
@@ -0,0 +1,31 @@
+package org.pgsg.reservation.presentation.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Getter
+@NoArgsConstructor
+public class ReservationCreateRequest {
+
+ @NotNull(message = "상품 ID는 필수입니다.")
+ private UUID productId;
+
+ @NotNull(message = "상품 이름은 필수입니다.")
+ private String productName;
+
+ @NotNull(message = "가격은 필수입니다.")
+ private Integer price;
+
+ @NotNull(message = "종료 시간은 필수 입니다.")
+ private LocalDateTime endTime;
+
+ @NotNull(message = "판매자 ID는 필수입니다.")
+ private UUID sellerId;
+
+ @NotNull(message = "판매자 닉네임은 필수입니다.")
+ private String sellerNickname;
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationSearchRequest.java b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationSearchRequest.java
new file mode 100644
index 0000000..36e9658
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/request/ReservationSearchRequest.java
@@ -0,0 +1,18 @@
+package org.pgsg.reservation.presentation.dto.request;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+import java.util.UUID;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class ReservationSearchRequest {
+ private String sellerName;
+ private String buyerName;
+ private String productName;
+ private ReservationStatus status;
+ private UUID productId;
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationCandidateResponse.java b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationCandidateResponse.java
new file mode 100644
index 0000000..046c7c3
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationCandidateResponse.java
@@ -0,0 +1,12 @@
+package org.pgsg.reservation.presentation.dto.response;
+
+import org.pgsg.reservation.application.dto.info.ReservationCandidateInfo;
+
+public record ReservationCandidateResponse(
+ ReservationCandidateInfo info,
+ String message
+) {
+ public static ReservationCandidateResponse of(ReservationCandidateInfo info, String message) {
+ return new ReservationCandidateResponse(info, message);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationDetailResponse.java b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationDetailResponse.java
new file mode 100644
index 0000000..c43af33
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationDetailResponse.java
@@ -0,0 +1,49 @@
+package org.pgsg.reservation.presentation.dto.response;
+
+import org.pgsg.reservation.application.dto.result.ReservationDetailResult;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record ReservationDetailResponse(
+ UUID reservationId,
+ ReservationStatus status,
+ ProductDetailInfo product,
+ SellerDetailInfo seller,
+ BuyerDetailInfo buyer,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+ public record ProductDetailInfo(
+ UUID productId,
+ String productName,
+ Integer price,
+ LocalDateTime endTime
+ ) {}
+
+ public record SellerDetailInfo(
+ UUID sellerId,
+ String sellerName
+ ) {}
+
+ public record BuyerDetailInfo(
+ UUID buyerId,
+ String buyerName
+ ) {}
+
+ public static ReservationDetailResponse from(ReservationDetailResult result) {
+ return new ReservationDetailResponse(
+ result.reservationId(),
+ result.status(),
+ new ProductDetailInfo(result.product().productId(), result.product().productName(), result.product().price(),result.product().endTime()),
+ new SellerDetailInfo(result.seller().sellerId(), result.seller().sellerName()),
+ result.buyer() != null ? new BuyerDetailInfo(
+ result.buyer().buyerId(),
+ result.buyer().buyerName()
+ ) : null,
+ result.createdAt(),
+ result.updatedAt()
+ );
+ }
+}
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationPageResponse.java b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationPageResponse.java
new file mode 100644
index 0000000..ae29a0e
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationPageResponse.java
@@ -0,0 +1,29 @@
+package org.pgsg.reservation.presentation.dto.response;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import org.pgsg.reservation.application.dto.result.CustomPage;
+import java.util.List;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
+@AllArgsConstructor
+public class ReservationPageResponse {
+ private final List content;
+ private final int pageNumber;
+ private final long totalElements;
+ private final int totalPages;
+
+ private ReservationPageResponse(CustomPage page) {
+ this.content = page.getContent();
+ this.pageNumber = page.getPageNumber();
+ this.totalElements = page.getTotalElements();
+ this.totalPages = (int) Math.ceil((double) page.getTotalElements() / page.getPageSize());
+ }
+
+ public static ReservationPageResponse from(CustomPage page) {
+ return new ReservationPageResponse<>(page);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationResponse.java b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationResponse.java
new file mode 100644
index 0000000..7c9263d
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationResponse.java
@@ -0,0 +1,52 @@
+package org.pgsg.reservation.presentation.dto.response;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import org.pgsg.reservation.application.dto.result.ReservationCreateResult;
+import org.pgsg.reservation.application.dto.result.ReservationSearchResult;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class ReservationResponse {
+
+ private UUID reservationId;
+ private String status;
+
+ // 목록 조회를 위해 추가된 필드들
+ private String productName;
+ private String sellerName;
+ private String buyerName;
+ private LocalDateTime createdAt;
+
+ /**
+ * 예약 생성 결과로부터 변환
+ */
+ public static ReservationResponse from(ReservationCreateResult result) {
+ return ReservationResponse.builder()
+ .reservationId(result.getReservationId())
+ .status(result.getStatus())
+ .productName(result.getProductName())
+ .sellerName(result.getSellerName())
+ .build();
+ }
+
+ /**
+ * 예약 목록 조회 결과로부터 변환
+ */
+ public static ReservationResponse from(ReservationSearchResult result) {
+ return ReservationResponse.builder()
+ .reservationId(result.reservationId())
+ .status(result.status().name())
+ .productName(result.productName())
+ .sellerName(result.sellerName())
+ .buyerName(result.buyerName() != null ? result.buyerName() : "NULL")
+ .createdAt(result.createdAt())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationStateResponse.java b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationStateResponse.java
new file mode 100644
index 0000000..b758550
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/dto/response/ReservationStateResponse.java
@@ -0,0 +1,25 @@
+package org.pgsg.reservation.presentation.dto.response;
+
+import lombok.Builder;
+import org.pgsg.reservation.application.dto.info.ReservationStateInfo;
+import org.pgsg.reservation.domain.model.reservation.ReservationStatus;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Builder
+public record ReservationStateResponse(
+ UUID reservationId,
+ ReservationStatus status,
+ LocalDateTime updatedAt,
+ String message
+) {
+ public static ReservationStateResponse of(ReservationStateInfo info, String message) {
+ return ReservationStateResponse.builder()
+ .reservationId(info.reservationId())
+ .status(info.status())
+ .updatedAt(info.updatedAt())
+ .message(message)
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/pgsg/reservation/presentation/facade/ReservationFacade.java b/src/main/java/org/pgsg/reservation/presentation/facade/ReservationFacade.java
new file mode 100644
index 0000000..4a86608
--- /dev/null
+++ b/src/main/java/org/pgsg/reservation/presentation/facade/ReservationFacade.java
@@ -0,0 +1,50 @@
+package org.pgsg.reservation.presentation.facade;
+
+import lombok.RequiredArgsConstructor;
+import org.pgsg.reservation.application.dto.command.ReservationApplyCommand;
+import org.pgsg.reservation.application.dto.info.ReservationCandidateInfo;
+import org.pgsg.reservation.application.service.ReservationService;
+import org.pgsg.reservation.domain.exception.ReservationErrorCode;
+import org.pgsg.reservation.domain.exception.ReservationException;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.TimeUnit;
+
+@Component
+@RequiredArgsConstructor
+public class ReservationFacade {
+
+ private final RedissonClient redissonClient;
+ private final ReservationService reservationService;
+
+ public ReservationCandidateInfo applyReservation(ReservationApplyCommand command) {
+ // 락의 키를 예약 자원 ID(reservationId) 단위로 설정하여 정밀하게 제어
+ String lockKey = "lock:reservation:" + command.reservationId();
+ RLock lock = redissonClient.getLock(lockKey);
+
+ try {
+ // waitTime: 1.5초 (락 획득을 위해 대기할 시간)
+ // leaseTime: -1초 (-1로 설정하여 Watchdog 활성화)
+ boolean available = lock.tryLock(1500, -1, TimeUnit.MILLISECONDS);
+
+ if (!available) {
+ // 락 획득 실패 시 커넥션을 소모하지 않고 바로 예외를 던져 Tomcat 스레드 블로킹 방지
+ throw new ReservationException(ReservationErrorCode.RESERVATION_BUSY);
+ }
+
+ // 락을 안전하게 획득한 단 하나의 스레드만 실제 DB 트랜잭션 서비스로 진입
+ return reservationService.proceedApplyTransaction(command);
+
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ReservationException(ReservationErrorCode.RESERVATION_INTERRUPTED);
+ } finally {
+ // 작업 완료 후 트랜잭션이 완전히 '커밋'된 시점에 안전하게 락을 해제
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application-reservation-error.yml b/src/main/resources/application-reservation-error.yml
new file mode 100644
index 0000000..b55766e
--- /dev/null
+++ b/src/main/resources/application-reservation-error.yml
@@ -0,0 +1,60 @@
+error:
+ configs:
+ # 예약 정보 유효성
+ "[reservation.validation.invalid-input]":
+ code: "R001"
+ message: "입력값이 누락되었습니다."
+ status: 400
+
+ "[reservation.validation.invalid-status]":
+ code: "R002"
+ message: "잘못된 예약 상태입니다."
+ status: 400
+
+ "[reservation.validation.invalid-select-status]":
+ code: "R003"
+ message: "잘못된 선정 상태입니다."
+ status: 400
+
+ # 비즈니스 예외
+ "[reservation.exception.cannot-change-status]":
+ code: "R004"
+ message: "현재 상태에서는 변경할 수 없는 요청입니다."
+ status: 400
+
+ "[reservation.exception.access-denied]":
+ code: "R005"
+ message: "해당 요청에 대한 접근 권한이 없습니다."
+ status: 403
+
+ "[reservation.exception.not-found.reservation]":
+ code: "R006"
+ message: "해당 예약을 찾을 수 없습니다."
+ status: 404
+
+ # 중복 및 충돌
+ "[reservation.exception.conflict.duplicate-reservation]":
+ code: "R007"
+ message: "이미 해당 상품에 대한 예약 진행 내역이 존재합니다."
+ status: 409
+
+ "[reservation.exception.conflict.already-exists]":
+ code: "R009"
+ message: "해당 상품으로 이미 생성된 예약 데이터가 존재합니다."
+ status: 409
+
+ # 분산 락 및 동시성 제어
+ "[reservation.exception.lock.busy]":
+ code: "R010"
+ message: "현재 접속자가 많아 예약 요청을 처리할 수 없습니다. 잠시 후 다시 시도해주세요."
+ status: 429
+
+ "[reservation.exception.lock.interrupted]":
+ code: "R011"
+ message: "예약 처리 중 대기 시간이 초과되었습니다. 다시 시도해주세요."
+ status: 500
+
+ "[reservation.exception.conflict.already-applied]":
+ code: "R008"
+ message: "이미 해당 예약에 신청한 후보자입니다."
+ status: 409
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..a846d19
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,115 @@
+server:
+ port: ${SERVER_PORT:8085}
+ tomcat:
+ threads:
+ max: 30
+ min-spare: 10
+
+spring:
+ main:
+ allow-bean-definition-overriding: true
+ application:
+ name: reservation-service
+ profiles:
+ active: ${SPRING_PROFILES_ACTIVE:dev,kafka}
+ include:
+ - error
+ - reservation-error
+
+ config:
+ import:
+ - "optional:file:.env[.properties]"
+ - "optional:configserver: "
+
+ cloud:
+ config:
+ discovery:
+ enabled: true
+ service-id: CONFIG-SERVER
+
+ datasource:
+ url: ${DB_URL}
+ username: ${DB_USERNAME}
+ password: ${DB_PASSWORD}
+ hikari:
+ maximum-pool-size: 30
+ minimum-idle: 15
+ connection-timeout: 3000
+ idle-timeout: 600000
+ max-lifetime: 1800000
+
+ kafka:
+ bootstrap-servers: ${KAFKA_HOST:34.64.35.152:9092,34.22.94.155:9092,34.50.28.59:9092}
+ ssl:
+ trust-store-location: ${KAFKA_SSL_PATH:file:/app/ssl/kafka.server.truststore.jks}
+ trust-store-password: ${KAFKA_SSL_TRUSTSTORE_PASSWORD}
+ properties:
+ security.protocol: SSL
+ ssl.endpoint.identification.algorithm: ""
+
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+
+ jpa:
+ hibernate:
+ ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:update}
+ show-sql: false
+ properties:
+ hibernate:
+ format_sql: true
+ use_sql_comments: true
+ defer-datasource-initialization: true
+
+management:
+ metrics:
+ tags:
+ application: ${spring.application.name}
+ tracing:
+ sampling:
+ probability: 1.0
+ zipkin:
+ tracing:
+ endpoint: http://34.47.69.9:9411/api/v2/spans
+ endpoints:
+ web:
+ exposure:
+ include: health, info, refresh, prometheus
+
+jwt:
+ secret: ${JWT_SECRET}
+
+eureka:
+ instance:
+ prefer-ip-address: true
+ ip-address: ${SERVER_IP:${spring.cloud.client.ip-address:localhost}}
+ instance-id: ${SERVER_IP:${spring.cloud.client.ip-address:localhost}}:${spring.application.name}:${server.port}
+ client:
+ enabled: ${EUREKA_ENABLED:true}
+ register-with-eureka: true
+ fetch-registry: true
+ service-url:
+ defaultZone: ${EUREKA_SERVER_URL:http://34.50.50.170:8761/eureka/}
+
+logging:
+ file:
+ name: /app/logs/reservation-service.log
+ logback:
+ rollingpolicy:
+ max-file-size: 10MB
+ max-history: 7
+ level:
+ root: INFO
+ org.springframework.data.redis: TRACE
+ org.springframework.cache: TRACE
+ com.fasterxml.jackson: TRACE
+
+topics:
+ reservation:
+ completed: prod-reservation-completed
+ cancelled: prod-reservation-cancelled
+ failed : prod-reservation-tradefail
+ product:
+ created: prod-product-created
+ failed: prod-product-failed
\ No newline at end of file
diff --git a/src/main/resources/ssl/kafka.server.truststore.jks b/src/main/resources/ssl/kafka.server.truststore.jks
new file mode 100644
index 0000000..17266ad
Binary files /dev/null and b/src/main/resources/ssl/kafka.server.truststore.jks differ