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 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