diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e37f5f7dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# Git 관련 +.git +.gitignore +.gitattributes + +# IDE 설정 +.idea +.vscode +*.iml +*.ipr +*.iws + +# Gradle 빌드 캐시 (builder 단계에서 새로 생성) +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar + +# 테스트 관련 +**/test/resources/test-data/ + +# 문서 +*.md +README* +docs/ +LICENSE + +# CI/CD +.github/ +.gitlab-ci.yml +Jenkinsfile + +# 환경 변수 (절대 포함되면 안됨) +.env +.env.* +doppler.env +*.env + +# Docker 관련 +docker/ +docker-compose*.yml +Dockerfile* +.dockerignore + +# 로그 +logs/ +*.log + +# OS 파일 +.DS_Store +Thumbs.db + +# 기타 +node_modules/ +bin/ +out/ +tmp/ +temp/ \ No newline at end of file diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 6a679e889..7544da9f2 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -35,15 +35,6 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: 🗂️ Make application-test.yml - run: | - mkdir -p ./src/main/resources - - echo "${{ secrets.APPLICATION_TEST_YML }}" > src/main/resources/application-test.yml - # Queue 시스템 비활성화 (CI 테스트용) - echo "" >> src/main/resources/application-test.yml - echo "queue:" >> src/main/resources/application-test.yml - echo " enabled: false" >> src/main/resources/application-test.yml - name: ✨ Gradlew 권한 설정 run: chmod +x ./gradlew diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dfe45ac57..d6679645e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,13 +7,18 @@ on: paths: - ".github/workflows/**" - "src/**" + - "build.gradle" + - "settings.gradle" - "build.gradle.kts" + - "settings.gradle.kts" + - "gradle/**" + - "gradlew" + - "gradlew.bat" - "Dockerfile" - "docker/**" branches: - develop -# 권한 최소화/명시화 permissions: contents: write packages: write @@ -65,8 +70,7 @@ jobs: - name: set lower case owner name id: export_owner run: | - # OWNER_LC="${GITHUB_REPOSITORY_OWNER,,}" - OWNER_LC="chehyeon-kim23" # 본인 아이디를 소문자로 직접 입력 + OWNER_LC="chehyeon-kim23" echo "owner_lc=$OWNER_LC" >> $GITHUB_OUTPUT - name: export image name @@ -78,14 +82,11 @@ jobs: with: context: . push: true - build-args: | - DOPPLER_TOKEN=${{ secrets.DOPPLER_TOKEN }} cache-from: type=registry,ref=ghcr.io/${{ steps.export_owner.outputs.owner_lc }}/${{ steps.export_image.outputs.image_name }}:cache cache-to: type=registry,ref=ghcr.io/${{ steps.export_owner.outputs.owner_lc }}/${{ steps.export_image.outputs.image_name }}:cache,mode=max tags: | ghcr.io/${{ steps.export_owner.outputs.owner_lc }}/${{ steps.export_image.outputs.image_name }}:${{ needs.makeTagAndRelease.outputs.tag_name }}, - ghcr.io/${{ steps.export_owner.outputs.owner_lc }}/${{ steps.export_image.outputs.image_name }}:latest - + ghcr.io/${{ steps.export_owner.outputs.owner_lc }}/${{ steps.export_image.outputs.image_name }}:latest deploy: runs-on: ubuntu-latest @@ -103,37 +104,53 @@ jobs: INSTANCE_ID=$(aws ec2 describe-instances --filters "Name=tag:Name,Values=TT-ec2-1" "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].InstanceId" --output text) echo "INSTANCE_ID=$INSTANCE_ID" >> $GITHUB_ENV - - name: AWS SSM Send-Command (Official CLI) + - name: AWS SSM Send-Command (Doppler 완전 통합) run: | - aws ssm send-command \ - --instance-ids "${{ env.INSTANCE_ID }}" \ - --document-name "AWS-RunShellScript" \ - --comment "Deploy Spring Boot with Prod Profile" \ - --parameters '{ - "commands": [ - "#!/bin/bash", - - "export HOME=/root", - "export PATH=$PATH:/usr/local/bin", - - - "git config --global --add safe.directory /dockerProjects/tt-src/WEB7_9_B2ST_BE", - - "cd /dockerProjects/tt-src/WEB7_9_B2ST_BE/ || exit 1", - "git fetch --all", - "git reset --hard origin/develop", - - - "cd docker/", - "export DOPPLER_TOKEN=\"${{ secrets.DOPPLER_TOKEN }}\"", - "echo \"${{ secrets.PERSONAL_ACCESS_TOKEN }}\" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 2>/dev/null", - - "doppler run -- docker compose pull", - "doppler run -- docker compose up -d --force-recreate", - - "docker image prune -f", - - "docker logout ghcr.io 2>/dev/null" - ] - }' \ - --region ${{ secrets.AWS_REGION }} + aws ssm send-command \ + --instance-ids "${{ env.INSTANCE_ID }}" \ + --document-name "AWS-RunShellScript" \ + --comment "Deploy with Doppler (all secrets managed centrally)" \ + --parameters '{ + "commands": [ + "#!/bin/bash", + "set -euo pipefail", + + "export HOME=/root", + "export PATH=$PATH:/usr/local/bin", + + "git config --global --add safe.directory /dockerProjects/tt-src/WEB7_9_B2ST_BE", + + "cd /dockerProjects/tt-src/WEB7_9_B2ST_BE/ || exit 1", + "git fetch --all", + "git reset --hard origin/develop", + + "cd docker/", + + "# Doppler 설정 (파일에서 토큰만 읽기)", + "export DOPPLER_TOKEN=\"$(sudo tr -d \"\\r\\n\" < /etc/tt-secrets/doppler-token)\"", + "export DOPPLER_PROJECT=tt", + "export DOPPLER_CONFIG=prd", + + "# GitHub 레지스트리 로그인 (Doppler에서 GITHUB_TOKEN 주입)", + "doppler run --project \"$DOPPLER_PROJECT\" --config \"$DOPPLER_CONFIG\" -- bash -c \"echo \\$GITHUB_TOKEN | docker login ghcr.io -u ${{ github.actor }} --password-stdin 2>/dev/null\"", + + "# Alertmanager 설정 파일 환경변수 치환", + "doppler run --project \"$DOPPLER_PROJECT\" --config \"$DOPPLER_CONFIG\" -- bash -lc \"envsubst < monitoring/alertmanager/alertmanager.yml > /tmp/alertmanager-resolved.yml\"", + "cp /tmp/alertmanager-resolved.yml monitoring/alertmanager/alertmanager.yml", + "rm -f /tmp/alertmanager-resolved.yml", + + "# Docker Compose 실행 (모든 환경변수 Doppler에서 주입)", + "doppler run --project \"$DOPPLER_PROJECT\" --config \"$DOPPLER_CONFIG\" -- docker compose pull", + "doppler run --project \"$DOPPLER_PROJECT\" --config \"$DOPPLER_CONFIG\" -- docker compose up -d --force-recreate", + + "# 정리", + "docker image prune -f", + "docker logout ghcr.io 2>/dev/null", + + "echo \"✅ Deployment completed at $(date)\"", + + "# 최종 상태 확인", + "doppler run --project \"$DOPPLER_PROJECT\" --config \"$DOPPLER_CONFIG\" -- docker compose ps" + ] + }' \ + --region ${{ secrets.AWS_REGION }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b35202913..b631e0b37 100644 --- a/.gitignore +++ b/.gitignore @@ -55,9 +55,17 @@ src/main/generated/ !src/main/resources/application.yml !src/main/resources/application-prod.yml !src/main/resources/application-test.yml +!docker/init-redis-cluster.sh +!docker/docker-compose.redis-cluster.yml +!docker/monitoring/prometheus/prometheus.yml +!docker/monitoring/prometheus/rules/*.yml +!docker/monitoring/grafana/provisioning/**/*.yml +!docker/monitoring/alertmanager/alertmanager.yml ### custom ### src/main/resources/application-secret.yml # local / sensitive files *.pem +error.log +docker/.env diff --git a/Deployment guide.md b/Deployment guide.md new file mode 100644 index 000000000..833e6eb3f --- /dev/null +++ b/Deployment guide.md @@ -0,0 +1,616 @@ +# TT Backend 배포 프로세스 문서 + +## 개요 + +이 문서는 TT Backend 애플리케이션의 자동화된 배포 프로세스를 설명합니다. GitHub Actions를 통해 트리거되며, Docker 컨테이너화된 Spring Boot 애플리케이션과 모니터링 스택을 AWS EC2 인스턴스에 배포합니다. + +--- + +## 배포 트리거 + +### 자동 배포 조건 + +배포는 `develop` 브랜치에 다음 경로의 파일이 변경되어 푸시될 때 자동으로 실행됩니다: + +- `.github/workflows/**` - GitHub Actions 워크플로우 +- `src/**` - 소스 코드 +- `build.gradle`, `settings.gradle` - Gradle 빌드 설정 +- `build.gradle.kts`, `settings.gradle.kts` - Kotlin DSL 빌드 설정 +- `gradle/**` - Gradle wrapper 파일 +- `gradlew`, `gradlew.bat` - Gradle wrapper 실행 파일 +- `Dockerfile` - Docker 이미지 빌드 설정 +- `docker/**` - Docker 관련 파일 + +--- + +## 배포 파이프라인 + +배포 프로세스는 3단계로 구성됩니다: + +``` +1. makeTagAndRelease (태그 생성 및 릴리스) + ↓ +2. buildImageAndPush (Docker 이미지 빌드 및 푸시) + ↓ +3. deploy (EC2 인스턴스 배포) +``` + +--- + +## 1단계: makeTagAndRelease + +### 목적 +자동으로 버전 태그를 생성하고 GitHub Release를 만듭니다. + +### 프로세스 +1. **태그 생성**: `mathieudutour/github-tag-action` 사용 + - Conventional Commits 기반 자동 버전 증가 + - 변경 로그 자동 생성 + +2. **릴리스 생성**: `actions/create-release` 사용 + - 생성된 태그로 GitHub Release 발행 + - 변경 로그를 릴리스 노트로 포함 + +### 출력 +- `tag_name`: 생성된 태그 이름 (다음 단계에서 사용) + +--- + +## 2단계: buildImageAndPush + +### 목적 +Spring Boot 애플리케이션의 Docker 이미지를 빌드하고 GitHub Container Registry(GHCR)에 푸시합니다. + +### Docker 이미지 빌드 과정 + +#### Dockerfile 구조 (멀티 스테이지 빌드) + +**Stage 1: Builder** +```dockerfile +FROM gradle:8.10.0-jdk21-alpine AS builder +``` +- Gradle 8.10.0과 JDK 21을 사용한 빌드 환경 +- 의존성 캐싱 레이어 최적화 +- `bootJar` 태스크로 실행 가능한 JAR 생성 (테스트 제외) + +**Stage 2: Runtime** +```dockerfile +FROM eclipse-temurin:21-jre-alpine +``` +- 최소화된 JRE 이미지 사용 (빌드 도구 미포함) +- 비-root 유저(`spring`) 생성 및 실행 (보안) +- 컨테이너 최적화된 JVM 옵션 적용 + +### JVM 최적화 설정 + +```bash +-XX:+UseContainerSupport # 컨테이너 환경 인식 +-XX:MaxRAMPercentage=75.0 # 최대 힙 메모리 75% +-XX:InitialRAMPercentage=50.0 # 초기 힙 메모리 50% +-XX:+UseG1GC # G1 가비지 컬렉터 사용 +-XX:+DisableExplicitGC # 명시적 GC 호출 비활성화 +``` + +### 이미지 푸시 + +- **레지스트리**: `ghcr.io/chehyeon-kim23/tt_backend` +- **태그**: + - ``: 릴리스 버전 태그 (예: v1.2.3) + - `latest`: 최신 이미지 태그 +- **캐시 전략**: + - 레지스트리 기반 캐시 사용 (빌드 속도 향상) + - Cache tag: `cache` + +--- + +## 3단계: deploy + +### 목적 +AWS EC2 인스턴스에 애플리케이션과 인프라를 배포합니다. + +### 배포 대상 인스턴스 +- **인스턴스 태그**: `TT-ec2-1` +- **상태**: `running` +- **명령 실행**: AWS Systems Manager (SSM) Send-Command + +### 배포 스크립트 상세 + +#### 3.1 환경 설정 +```bash +export HOME=/root +export PATH=$PATH:/usr/local/bin +git config --global --add safe.directory /dockerProjects/tt-src/WEB7_9_B2ST_BE +``` + +#### 3.2 소스 코드 동기화 +```bash +cd /dockerProjects/tt-src/WEB7_9_B2ST_BE/ +git fetch --all +git reset --hard origin/develop +``` +- 원격 저장소의 최신 `develop` 브랜치로 강제 동기화 + +#### 3.3 보안 토큰 관리 + +**Doppler Token (환경 변수 관리)** +```bash +export DOPPLER_TOKEN="$(sudo tr -d "\r\n" < /etc/tt-secrets/doppler-token)" +export DOPPLER_PROJECT=tt +export DOPPLER_CONFIG=prd +``` +- Doppler를 통해 안전하게 환경 변수 주입 +- Trailing newline/CRLF 제거로 안정성 확보 +- EC2 파일 시스템에 유일하게 저장되는 시크릿 (Doppler 접근용) + +**GitHub Token (컨테이너 레지스트리 인증)** +```bash +doppler run --project "$DOPPLER_PROJECT" --config "$DOPPLER_CONFIG" -- bash -c "echo \$GITHUB_TOKEN | docker login ghcr.io -u --password-stdin" +``` +- Doppler에서 GITHUB_TOKEN 환경 변수로 주입 +- EC2 파일 시스템에 평문 저장 안 함 (보안 강화) +- SSM 로그에 토큰 값 노출 방지 + +#### 3.4 Alertmanager 설정 치환 +```bash +doppler run -- bash -lc "envsubst < monitoring/alertmanager/alertmanager.yml > /tmp/alertmanager-resolved.yml" +cp /tmp/alertmanager-resolved.yml monitoring/alertmanager/alertmanager.yml +rm -f /tmp/alertmanager-resolved.yml +``` +- 환경 변수(예: Slack Webhook)를 설정 파일에 주입 +- 임시 파일 사용 후 삭제 + +#### 3.5 컨테이너 배포 +```bash +doppler run -- docker compose pull +doppler run -- docker compose up -d --force-recreate +``` +- 모든 Docker Compose 명령을 Doppler 환경에서 실행 +- 환경 변수 일관성 보장 + +#### 3.6 정리 작업 +```bash +docker image prune -f +docker logout ghcr.io +``` +- 미사용 이미지 제거 (디스크 공간 확보) +- 레지스트리 로그아웃 (보안) + +--- + +## 인프라 구성 (Docker Compose) + +배포되는 전체 스택은 다음과 같이 구성됩니다: + +### 데이터베이스 계층 + +#### PostgreSQL +- **이미지**: `postgres:16-alpine` +- **포트**: `5432` +- **데이터 영속성**: `postgres_data` 볼륨 +- **Healthcheck**: `pg_isready` 명령 +- **환경 변수**: + - `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` + - UTF-8 인코딩 강제 + +#### Redis Cluster (6 노드) +- **이미지**: `redis:7-alpine` +- **노드 구성**: + - Master: 3개 (7000-7002) + - Replica: 3개 (7003-7005) +- **클러스터 모드**: 활성화 +- **인증**: `REDIS_PASSWORD` 필수 +- **데이터 영속성**: + - AOF(Append Only File) 활성화 + - RDB 스냅샷 (900초/1건, 300초/10건, 60초/10000건) + +##### Redis Cluster 초기화 (`init-cluster.sh`) +```bash +redis-cli --cluster create \ + redis-node-1:7000 ... redis-node-6:7005 \ + --cluster-replicas 1 \ + --cluster-yes +``` +- 자동으로 마스터/복제본 할당 +- 클러스터 상태 확인 후 중복 초기화 방지 +- 모든 노드 healthcheck 완료 후 실행 + +### 애플리케이션 계층 + +#### Spring Boot App +- **이미지**: `ghcr.io/chehyeon-kim23/tt_backend:latest` +- **포트**: `8080` +- **프로필**: `prod` +- **의존성**: + - PostgreSQL (healthcheck 대기) + - Redis Cluster (초기화 완료 대기) + +**환경 변수 카테고리**: +1. **데이터베이스**: Spring 표준 + 커스텀 키 (호환성) +2. **Redis**: 클러스터 모드 노드 목록 +3. **메일**: SMTP 설정 (Gmail 등) +4. **AWS**: S3 버킷, 리전 정보 +5. **보안**: JWT 시크릿, 만료 시간 +6. **OAuth**: Kakao 로그인 설정 +7. **알림**: Slack Webhook +8. **JPA**: DDL 자동 생성 모드 + +**Healthcheck**: +```bash +wget --quiet --tries=1 --spider http://localhost:8080/actuator/health +``` +- Spring Boot Actuator 헬스 엔드포인트 +- 60초 시작 유예 시간 + +### 모니터링 스택 + +#### Prometheus +- **이미지**: `prom/prometheus:v3.8.1` +- **포트**: `9090` +- **기능**: + - 메트릭 수집 및 저장 + - 알림 규칙 평가 + - 15일 데이터 보존 +- **스크랩 대상**: + - Spring Boot App (`/actuator/prometheus`) + - Redis Exporter + - PostgreSQL Exporter + +#### Grafana +- **이미지**: `grafana/grafana:12.3.0` +- **포트**: `3001` (호스트) → `3000` (컨테이너) +- **기능**: + - 대시보드 시각화 + - Prometheus 데이터 소스 자동 프로비저닝 + - 사전 구성된 대시보드 +- **인증**: Admin 계정 (비밀번호는 환경 변수) + +#### Alertmanager +- **이미지**: `prom/alertmanager:v0.30.0` +- **포트**: `9093` +- **기능**: + - 알림 라우팅 및 그룹화 + - Slack 알림 전송 + - 중복 알림 억제 + +**설정 파일 주입**: +```yaml +# alertmanager.yml에서 환경 변수 사용 +slack_api_url: ${SLACK_WEBHOOK_AUTH} +``` +- Doppler 환경에서 `envsubst` 실행 +- 민감 정보 파일에 저장 안 함 + +#### Redis Exporter +- **이미지**: `oliver006/redis_exporter:v1.80.1` +- **포트**: `9121` +- **대상**: `redis-node-1:7000` (대표 노드) +- **메트릭**: 커넥션, 메모리, 키스페이스 등 + +#### PostgreSQL Exporter +- **이미지**: `prometheuscommunity/postgres-exporter:v0.15.0` +- **포트**: `9187` +- **연결**: `DATA_SOURCE_NAME` (PostgreSQL DSN) +- **메트릭**: 쿼리 성능, 테이블 통계, 연결 풀 등 + +--- + +## 네트워크 및 볼륨 + +### 네트워크 +- **이름**: `common` +- **드라이버**: `bridge` +- **목적**: 모든 서비스 간 통신 + +### 영속 볼륨 +```yaml +volumes: + postgres_data # PostgreSQL 데이터 + redis-node-1-data # Redis 노드 1-6 데이터 + redis-node-2-data + redis-node-3-data + redis-node-4-data + redis-node-5-data + redis-node-6-data + prometheus_data # 메트릭 데이터 + grafana_data # 대시보드 설정 + alertmanager_data # 알림 상태 +``` + +--- + +## 환경 변수 관리 + +### Doppler 사용 이유 +1. **중앙 집중화**: 모든 환경 변수를 Doppler에서 관리 +2. **버전 관리**: 변경 이력 추적 +3. **보안**: 평문 노출 없이 주입 +4. **일관성**: 모든 명령에서 동일한 환경 보장 + +### 환경 변수 흐름 +``` +Doppler Cloud (모든 시크릿 중앙 관리) + ↓ +EC2 Instance (/etc/tt-secrets/doppler-token) ← 유일한 EC2 파일 시크릿 + ↓ +doppler run -- docker compose (환경 변수 주입) + ↓ +Container Environment +``` + +### Doppler에서 관리하는 시크릿 +- **GITHUB_TOKEN**: GitHub Container Registry 인증 +- **POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB**: 데이터베이스 인증 +- **REDIS_PASSWORD**: Redis 클러스터 인증 +- **MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD**: 이메일 설정 +- **AWS_REGION, AWS_S3_BUCKET**: AWS 리소스 설정 +- **JWT_SECRET, JWT_ACCESS_EXPIRATION, JWT_REFRESH_EXPIRATION**: JWT 토큰 설정 +- **KAKAO_CLIENT_ID, KAKAO_CLIENT_SECRET**: OAuth 설정 +- **SLACK_WEBHOOK_AUTH**: Slack 알림 Webhook +- **GRAFANA_PASSWORD**: Grafana 관리자 비밀번호 +- 기타 모든 애플리케이션 환경 변수 + +### EC2 파일 시스템에 저장되는 시크릿 +- **DOPPLER_TOKEN**: Doppler API 접근 토큰 (`/etc/tt-secrets/doppler-token`) + - 이 토큰만 파일로 저장 (Doppler 접근에 필요) + - 권한: 400 (root만 읽기 가능) + - 다른 모든 시크릿은 Doppler에서 관리 + +--- + +## 보안 고려사항 + +### 1. 비-root 컨테이너 실행 +```dockerfile +RUN addgroup -S spring && adduser -S spring -G spring +USER spring +``` + +### 2. 토큰 관리 +- **GitHub Token**: Doppler에서 관리, EC2 파일 시스템에 저장 안 함 +- **Doppler Token**: EC2 파일에서 읽기 전용 접근 (`/etc/tt-secrets/doppler-token`) +- **환경 변수**: Doppler를 통해 안전하게 주입, 평문 노출 없음 +- **SSM 로그**: 실제 토큰 값이 AWS 로그에 기록되지 않음 + +### 3. 네트워크 격리 +- 모든 서비스는 `common` 네트워크 내부에서만 통신 +- 필요한 포트만 호스트에 노출 + +### 4. 인증 +- Redis: `requirepass` + `masterauth` +- PostgreSQL: 사용자/비밀번호 +- Grafana: Admin 계정 비밀번호 + +--- + +## 헬스체크 전략 + +모든 서비스는 헬스체크를 통해 의존성 순서를 보장합니다: + +``` +PostgreSQL (healthy) + ↓ +Redis Cluster Nodes (all healthy) + ↓ +Redis Cluster Init (completed successfully) + ↓ +Spring Boot App (healthy) + ↓ +Monitoring Stack (Prometheus, Grafana, Alertmanager) +``` + +**헬스체크 실패 시**: +- 컨테이너 재시작 (restart: always) +- 의존 서비스 대기 (depends_on conditions) + +--- + +## 롤백 전략 + +### 자동 롤백 (없음) +현재 파이프라인은 자동 롤백을 지원하지 않습니다. + +### 수동 롤백 방법 +1. **이전 버전 태그로 이미지 변경**: + ```bash + docker pull ghcr.io/chehyeon-kim23/tt_backend:v1.2.2 + # docker-compose.yml 수정 또는 환경 변수 변경 + docker compose up -d --force-recreate app + ``` + +2. **Git 리버트 후 재배포**: + ```bash + git revert + git push origin develop + # GitHub Actions 자동 재배포 + ``` + +--- + +## 모니터링 및 알림 + +### Prometheus 메트릭 +- **Spring Boot**: JVM, HTTP 요청, 데이터베이스 연결 +- **Redis**: 메모리 사용량, 키 개수, 커넥션 +- **PostgreSQL**: 쿼리 성능, 테이블 크기, 락 + +### Grafana 대시보드 +- 프로비저닝된 대시보드: `/var/lib/grafana/dashboards` +- 데이터 소스: Prometheus (자동 구성) + +### Alertmanager 알림 +- **대상**: Slack Webhook +- **설정**: `monitoring/alertmanager/alertmanager.yml` +- **알림 예시**: + - 애플리케이션 다운 + - 높은 메모리 사용량 + - 데이터베이스 연결 실패 + +--- + +## 트러블슈팅 + +### 1. 배포 실패 +**증상**: SSM Send-Command 실패 + +**확인 사항**: +- EC2 인스턴스 상태 (`running`) +- SSM Agent 실행 여부 +- IAM 역할 권한 (SSM, ECR) + +### 2. 컨테이너 시작 실패 +**증상**: `docker compose up` 오류 + +**확인 사항**: +```bash +docker compose logs +docker compose ps +``` + +**일반적인 원인**: +- 환경 변수 누락 (Doppler 토큰 확인) +- 포트 충돌 +- 디스크 공간 부족 + +### 3. Redis Cluster 초기화 실패 +**증상**: `redis-cluster-init` 컨테이너가 재시작 반복 + +**확인 사항**: +```bash +docker compose logs redis-cluster-init +redis-cli -h redis-node-1 -p 7000 -a cluster info +``` + +**해결 방법**: +```bash +# 클러스터 초기화 재시도 +docker compose restart redis-cluster-init + +# 또는 전체 Redis 스택 재시작 +docker compose down +docker volume rm +docker compose up -d +``` + +### 4. Healthcheck 타임아웃 +**증상**: 서비스가 `unhealthy` 상태 + +**확인 사항**: +```bash +# Spring Boot 헬스 엔드포인트 +curl http://localhost:8080/actuator/health + +# PostgreSQL +docker compose exec postgres pg_isready + +# Redis +docker compose exec redis-node-1 redis-cli -a ping +``` + +### 5. Doppler 인증 실패 +**증상**: `Invalid Auth token` 또는 `Unable to fetch secrets` + +**확인 사항**: +```bash +# Doppler 토큰 파일 존재 확인 +ls -la /etc/tt-secrets/doppler-token + +# 토큰 파일 권한 확인 (400 또는 600이어야 함) +sudo chmod 400 /etc/tt-secrets/doppler-token + +# Doppler 토큰 테스트 +sudo bash -c 'export DOPPLER_TOKEN="$(tr -d "\r\n" < /etc/tt-secrets/doppler-token)" && doppler secrets --project tt --config prd' +``` + +**해결 방법**: +- Doppler 토큰이 만료된 경우: Doppler 대시보드에서 새 토큰 생성 후 파일 업데이트 +- 파일 권한 문제: `sudo chmod 400 /etc/tt-secrets/doppler-token` + +### 6. GitHub Container Registry 로그인 실패 +**증상**: `unauthorized: authentication required` + +**확인 사항**: +```bash +# Doppler에 GITHUB_TOKEN이 있는지 확인 +sudo bash -c 'export DOPPLER_TOKEN="$(tr -d "\r\n" < /etc/tt-secrets/doppler-token)" && doppler secrets get GITHUB_TOKEN --project tt --config prd --plain' +``` + +**해결 방법**: +1. Doppler에 GITHUB_TOKEN 추가: + ```bash + doppler secrets set GITHUB_TOKEN= --project tt --config prd + ``` +2. GitHub PAT 권한 확인 (`packages:read`, `packages:write` 필요) +3. PAT가 만료된 경우 새로 생성하여 Doppler에 업데이트 + +--- + +## 성능 최적화 + +### 1. Docker 빌드 캐싱 +- 레지스트리 기반 캐시 사용 +- 의존성 레이어 분리 (변경 적음) + +### 2. JVM 튜닝 +- G1GC 사용 (낮은 지연) +- 컨테이너 메모리 인식 +- Heap 크기 자동 조정 + +### 3. Redis 데이터 영속성 +- AOF + RDB 조합 +- 클러스터 모드로 고가용성 + +### 4. PostgreSQL 연결 풀 +- Spring Boot 기본 HikariCP +- 환경 변수로 풀 크기 조정 가능 + +--- + +## 체크리스트 + +### 배포 전 +- [ ] Doppler에 모든 환경 변수 설정 완료 (GITHUB_TOKEN 포함) +- [ ] `/etc/tt-secrets/doppler-token` 파일 존재 및 권한 확인 (400) +- [ ] EC2 인스턴스 상태 확인 +- [ ] 디스크 공간 확인 (최소 10GB 여유) + +### 배포 중 +- [ ] GitHub Actions 워크플로우 성공 +- [ ] Docker 이미지 GHCR에 푸시 확인 +- [ ] SSM 명령 실행 성공 + +### 배포 후 +- [ ] Spring Boot 애플리케이션 헬스체크 통과 +- [ ] Prometheus 메트릭 수집 확인 +- [ ] Grafana 대시보드 접근 가능 +- [ ] Alertmanager Slack 알림 테스트 + +--- + +## 관련 문서 + +- **Doppler 문서**: https://docs.doppler.com +- **Doppler CLI**: https://docs.doppler.com/docs/cli +- **Doppler Service Tokens**: https://docs.doppler.com/docs/service-tokens +- **Docker Compose**: https://docs.docker.com/compose +- **Redis Cluster**: https://redis.io/topics/cluster-tutorial +- **Prometheus**: https://prometheus.io/docs +- **Grafana**: https://grafana.com/docs +- **GitHub PAT**: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry + +--- + +## 변경 이력 + +### 2025-01-11: Doppler 완전 통합 +- GitHub Token을 EC2 파일에서 Doppler로 마이그레이션 +- `/etc/tt-secrets/github-token` 파일 제거 +- 모든 시크릿을 Doppler에서 중앙 관리 +- 보안 강화: EC2 파일 시스템에 평문 시크릿 저장 최소화 + +--- + +## 연락처 + +배포 관련 문제 발생 시: +- GitHub Issues: 프로젝트 저장소 +- Slack: Alertmanager 알림 채널 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 253cbc7fd..6bb4f2fbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,44 @@ -# Gradle로 Spring Boot JAR 빌드 -FROM gradle:8.10.0-jdk21 AS builder +# 1단계: Gradle로 Spring Boot JAR 빌드 +FROM gradle:8.10.0-jdk21-alpine AS builder WORKDIR /app -# Gradle 설정 파일 먼저 복사 +# 의존성 캐싱 레이어 COPY build.gradle settings.gradle ./ COPY gradle gradle COPY gradlew . -RUN chmod +x gradlew +RUN chmod +x gradlew && \ + ./gradlew dependencies --no-daemon -# 종속성 설치 -RUN ./gradlew dependencies --no-daemon - -# 소스 코드 복사 +# 애플리케이션 빌드 COPY src src - -# 애플리케이션 빌드(테스트 제외) RUN ./gradlew bootJar --no-daemon -x test -# 2단계: 실행용 이미지 -FROM eclipse-temurin:21-jre -WORKDIR /app +# 2단계: 실행용 이미지 (최소화) +FROM eclipse-temurin:21-jre-alpine -# --- 도플러 CLI 설치 추가 --- -RUN apt-get update && apt-get install -y curl gnupg && \ - (curl -Ls https://cli.doppler.com/install.sh || wget -qO- https://cli.doppler.com/install.sh) | sh -# ------------------------- +# 보안: 비-root 유저 생성 +RUN addgroup -S spring && adduser -S spring -G spring +WORKDIR /app + +# JAR 파일만 복사 COPY --from=builder /app/build/libs/*.jar app.jar +# 소유권 변경 +RUN chown spring:spring app.jar + +# 비-root 유저로 전환 +USER spring + EXPOSE 8080 -CMD ["sh", "-c", "doppler run -- java -Dspring.profiles.active=prod -jar app.jar"] \ No newline at end of file +# 성능 최적화된 JVM 옵션 +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:InitialRAMPercentage=50.0", \ + "-XX:+UseG1GC", \ + "-XX:+DisableExplicitGC", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", "app.jar"] diff --git a/README.md b/README.md index a1817bd24..b34c0db35 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,378 @@ -# WEB7_9_B2ST_BE -데브코스 백엔드 7기 9회차 최종 프로젝트 Team B2 B2ST +# TT (Ticket & Trade) - Backend + +> 공연 예매(대기열/좌석 선점/추첨·신청 예매)와 티켓 거래(교환/양도), 결제 흐름을 지원하는 Spring Boot 기반 백엔드 서버 + +

+ Java 21 + Spring Boot 4.0.0 + PostgreSQL + Redis Cluster + Docker +

+ +## 📋 목차 + +- 🎯 [프로젝트 개요](#-프로젝트-개요) +- 👥 [팀 구성](#-팀-구성) +- 🔗 [링크](#-링크) +- ⚙️ [핵심 기능](#️-핵심-기능) +- 🛠️ [기술 스택](#️-기술-스택) +- 🏗️ [시스템 아키텍처](#️-시스템-아키텍처) +- 🗂️ [ERD](#️-erd) +- 🗂️ [프로젝트 구조](#-프로젝트-구조) +- 📊 [모니터링 구성](#-모니터링-구성) +- 🧩 [협업 규칙](#-협업-규칙) + +--- + +## 🎯 프로젝트 개요 + +image + +**TT(Ticket & Trade)** 는 공연 티켓 예매 및 2차 거래(교환/양도) 플랫폼의 백엔드 서버입니다. + + +### 주요 도메인 + +| 도메인 | 설명 | +|:------------|:---------------------------------------| +| **대기열** | Redis 기반 트래픽 분산, 순차 접근 제어 | +| **좌석 예매** | 좌석 선점(HOLD → SOLD), 만료 자동 복구 | +| **추첨 예매** | 등급별 응모, 공정 추첨, 당첨 알림 | +| **사전신청 예매** | 오픈/마감 정책 기반 신청 처리 | +| **거래** | 티켓 소유권 검증, 교환·양도 흐름 | +| **결제** | 도메인별 결제 흐름 분리, 상태 관리 | +| **인증** | JWT + Refresh Token Rotation, 카카오 OIDC | + +--- + +## 👥 팀 구성 + +| 이름 | 역할 | GitHub | +|:--------------:|:-------:|:----------------------------------------------------------------------------------------------------------------------------------:| +| 김채현 | Backend (PO) | [![GitHub](https://img.shields.io/badge/-Chehyeon--Kim23-181717?logo=github&logoColor=white)](https://github.com/Chehyeon-Kim23) | +| 김채현 | Backend | [![GitHub](https://img.shields.io/badge/-whyin-181717?logo=github&logoColor=white)](https://github.com/whyin) | +| 노미경 | Backend | [![GitHub](https://img.shields.io/badge/-77r77r-181717?logo=github&logoColor=white)](https://github.com/77r77r) | +| 박민형 | Backend (팀장) | [![GitHub](https://img.shields.io/badge/-minibr-181717?logo=github&logoColor=white)](https://github.com/minibr) | +| 이위림 | Backend | [![GitHub](https://img.shields.io/badge/-weilim0513--tech-181717?logo=github&logoColor=white)](https://github.com/weilim0513-tech) | + +--- + +## 🔗 링크 + +| 구분 | 링크 | +|:-----------:|:--------------------------------------------------------------------------------------:| +| 🌐 배포 | [https://doncrytt.vercel.app](https://doncrytt.vercel.app/) | +| 💻 Frontend | [WEB7_9_B2ST_FE](https://github.com/prgrms-web-devcourse-final-project/WEB7_9_B2ST_FE) | +| 🔧 Backend | [WEB7_9_B2ST_BE](https://github.com/prgrms-web-devcourse-final-project/WEB7_9_B2ST_BE) | +| 📖 API 문서 | [Swagger UI](http://15.165.115.135:8080/swagger-ui/index.html#/) | + +--- + +## ⚙️ 핵심 기능 + +### 🔐 인증/인가 (Auth) + +| 기능 | 구현 상세 | +|:---------------|:-----------------------------------------------------------------------| +| JWT 인증 | Access Token(30분) + Refresh Token(7일, Redis 저장) | +| Token Rotation | 재발급 시 Family/Generation 기반 탈취 감지, 이전 토큰 사용 시 전체 세션 무효화 | +| 카카오 OIDC | ID Token RSA 서명 검증(JWKS 24시간 캐싱), nonce 1회성 검증, 자동 계정 연동 | +| 로그인 보안 | 5회 실패 시 10분 잠금(Redis TTL), Lua Script 원자적 카운팅 | +| 위협 탐지 | Credential Stuffing(IP당 10+ 계정), Brute Force(IP당 50+ 실패) 탐지 → Slack 알림 | + +### 👤 회원 (Member) + +| 기능 | 구현 상세 | +|:-------|:--------------------------------------------------| +| 회원가입 | BCrypt 암호화, IP별 Rate Limiting(시간당 3회, Lua Script) | +| 이메일 인증 | SecureRandom 6자리 코드, Redis TTL 5분, 시도 횟수 제한(5회) | +| 탈퇴/복구 | Soft Delete + 30일 복구 유예, 복구 토큰(UUID, 24시간 TTL) | +| 감사 로그 | 로그인/가입 이벤트 비동기 저장(@Async + REQUIRES_NEW) | + +### ⏳ 대기열 (Queue) + +| 기능 | 구현 상세 | +|:-------|:---------------------------------------| +| 대기열 진입 | Redis Sorted Set 기반, 타임스탬프 스코어로 순서 보장 | +| 순번 조회 | ZRANK 명령으로 실시간 대기 순번 반환 | +| 입장 처리 | 순차적 입장 토큰 발급, 유효 시간 제한 | +| 상태 관리 | WAITING → PROCESSING → COMPLETED 상태 전이 | +| 환경 설정 | 대기열 on/off 설정 가능, 트래픽 상황에 따라 유연하게 적용 | + +### 🪑 좌석 예매 (Reservation) + +| 기능 | 구현 상세 | +|:------|:-------------------------------------------| +| 좌석 선점 | AVAILABLE → HOLD 상태 전이, 5분 TTL 설정 | +| 중복 방지 | 동일 좌석 동시 선점 시도 시 낙관적 락으로 충돌 감지 | +| 예매 확정 | 결제 완료 시 HOLD → SOLD 상태 전이 | +| 선점 만료 | 스케줄러 기반 TTL 만료 좌석 자동 복구 (HOLD → AVAILABLE) | +| 예매 취소 | 예매 취소 시 좌석 상태 원복, 환불 처리 연동 | + +### 🎲 추첨 예매 (Lottery) + +| 기능 | 구현 상세 | +|:-------|:---------------------------| +| 응모 등록 | 회차/등급별 응모, 중복 응모 검증 | +| 응모 제한 | 1인당 등급별 최대 응모 수량 제한 | +| 추첨 처리 | SecureRandom 기반 공정 추첨 알고리즘 | +| 당첨 처리 | 당첨자 좌석 자동 배정, 결제 기한 설정 | +| 결과 알림 | 당첨/낙첨 이메일 비동기 발송 | +| 미결제 처리 | 결제 기한 초과 시 자동 당첨 취소, 좌석 반환 | + +### 📝 사전신청 예매 (Pre-Reservation) + +| 기능 | 구현 상세 | +|:------|:------------------------| +| 신청 기간 | 오픈/마감 일시 기반 신청 가능 기간 검증 | +| 신청 등록 | 회차/등급별 사전신청, 수량 지정 | +| 신청 확정 | 신청 → 결제 대기 → 결제 완료 흐름, 예외: CANCELLED, EXPIRED| +| 만료 처리 | 결제 기한 초과 시 신청 자동 만료, 결제 시도 차단 | +| 신청 취소 | 사용자 요청에 의한 신청 취소 처리 | + +### 💳 결제 (Payment) + +| 기능 | 구현 상세 | +|:--------|:--------------------------------------------------| +| 도메인별 분리 | 좌석예매/추첨/사전신청/거래별 결제 생성/검증 로직 독립 | +| 상태 관리 | PENDING → PROCESSING → COMPLETED/FAILED/CANCELLED (전이 규칙 명시) | + +### 🔁 거래 (Trade) + +| 기능 | 구현 상세 | +|:-------|:-----------------------| +| 거래 등록 | 티켓 소유권 검증, 중복 등록 방지 | +| 거래 요청 | 구매자 거래 요청, 판매자 승인 대기 | +| 임시 점유 | 승인 시 redis에 티켓 임시 점유 등록, 결제 기한까지 재거래/재예약 요청 즉시 차단(최종 확정은 DB로 검증) | +| 거래 승인 | 판매자 승인 시 결제 프로세스 진입 | +| 소유권 이전 | 결제 완료 시 티켓 소유권 구매자로 변경 | + +### 🧑‍💼 관리자 (Admin) + +| 기능 | 구현 상세 | +|:------|:------------------------------------------------| +| 회원 관리 | 검색/필터링/페이징, 대시보드 통계 | +| 인증 관리 | 로그인/가입 로그 조회, 계정 잠금 해제 | +| 권한 분리 | `/api/admin/**` URL 레벨 + `@PreAuthorize` 메서드 레벨 | + +--- + +## 🛠️ 기술 스택 + +### \ +![Java](https://img.shields.io/badge/Java-ED8B00?logo=openjdk&logoColor=white&style=for-the-badge) +![Spring Boot](https://img.shields.io/badge/Spring%20Boot-6DB33F?logo=springboot&logoColor=white&style=for-the-badge) +![Spring Security](https://img.shields.io/badge/Spring%20Security-6DB33F?logo=springsecurity&logoColor=white&style=for-the-badge) +![Spring Data JPA](https://img.shields.io/badge/Spring%20Data%20JPA-0D6EFD?logo=spring&logoColor=white&style=for-the-badge) +![QueryDSL](https://img.shields.io/badge/QueryDSL-000000?style=for-the-badge) +![Gradle](https://img.shields.io/badge/Gradle-02303A?logo=gradle&logoColor=white&style=for-the-badge) +![Swagger](https://img.shields.io/badge/Swagger-85EA2D?logo=swagger&logoColor=black&style=for-the-badge) + +### \ +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white&style=for-the-badge) +![H2 Database](https://img.shields.io/badge/H2%20Database-1A73E8?logo=h2database&logoColor=white&style=for-the-badge) +![Redis](https://img.shields.io/badge/Redis-DC382D?logo=redis&logoColor=white&style=for-the-badge) + +### \ +![Grafana](https://img.shields.io/badge/Grafana-F46800?logo=grafana&logoColor=white&style=for-the-badge) +![Prometheus](https://img.shields.io/badge/Prometheus-E6522C?logo=prometheus&logoColor=white&style=for-the-badge) +![Doppler](https://img.shields.io/badge/Doppler-000000?logo=doppler&logoColor=white&style=for-the-badge) +![Micrometer](https://img.shields.io/badge/Micrometer-000000?style=for-the-badge) + +### \ +![Terraform](https://img.shields.io/badge/Terraform-7B42BC?logo=terraform&logoColor=white&style=for-the-badge) +![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white&style=for-the-badge) +![AWS](https://img.shields.io/badge/AWS-232F3E?logo=amazonaws&logoColor=white&style=for-the-badge) +![Amazon%20EC2](https://img.shields.io/badge/Amazon%20EC2-FF9900?logo=amazonec2&logoColor=black&style=for-the-badge) +![Amazon%20S3](https://img.shields.io/badge/Amazon%20S3-569A31?logo=amazons3&logoColor=white&style=for-the-badge) + +### \ +![JWT](https://img.shields.io/badge/JWT-000000?logo=jsonwebtokens&logoColor=white&style=for-the-badge) +![KAKAO OAuth](https://img.shields.io/badge/KAKAO%20OAuth-FFCD00?style=for-the-badge&logo=kakaotalk&logoColor=000000) +![SMTP](https://img.shields.io/badge/SMTP-000000?style=for-the-badge) +![Slack Webhook](https://img.shields.io/badge/Slack%20Webhook-4A154B?logo=slack&logoColor=white&style=for-the-badge) +![Swagger](https://img.shields.io/badge/Swagger-85EA2D?logo=swagger&logoColor=black&style=for-the-badge) +![Postman](https://img.shields.io/badge/Postman-FF6C37?logo=postman&logoColor=white&style=for-the-badge) +![Testcontainers](https://img.shields.io/badge/Testcontainers-000000?logo=testcontainers&logoColor=white&style=for-the-badge) +![JUnit](https://img.shields.io/badge/JUnit-25A162?logo=junit5&logoColor=white&style=for-the-badge) + +--- + +## 🏗️ 시스템 아키텍처 + +Image + +--- + +## 🗂️ ERD + +![Image](https://github.com/user-attachments/assets/3c393c6a-5186-444d-80fc-b69c17f406c1) + +--- + +## 📁 프로젝트 구조 + +
+펼쳐보기 + +``` +src/main/java/com/back/b2st/ +├── domain/ +│ ├── auth/ # JWT, OAuth, 로그인 보안, 토큰 관리 +│ ├── member/ # 회원 CRUD, 탈퇴/복구, Rate Limiting +│ ├── email/ # 이메일 인증, 비동기 발송 +│ ├── performance/ # 공연 관리 +│ ├── performanceschedule/# 공연 회차 +│ ├── seat/ # 좌석 관리 +│ ├── scheduleseat/ # 회차별 좌석 상태 +│ ├── queue/ # 대기열 시스템 +│ ├── reservation/ # 좌석 예매 +│ ├── prereservation/ # 사전 신청 +│ ├── lottery/ # 추첨 예매 +│ ├── payment/ # 결제 +│ ├── ticket/ # 티켓 관리 +│ ├── trade/ # 거래 (교환/양도) +│ ├── venue/ # 공연장 +│ └── bank/ # 은행 코드 Enum +│ +├── global/ +│ ├── alert/ # SlackAlertService (Webhook 연동) +│ ├── config/ # Redis, S3, Redisson, Alert 설정 +│ ├── error/ # GlobalExceptionHandler, ErrorCode +│ ├── jwt/ # JwtTokenProvider, JwtAuthenticationFilter +│ ├── jpa/ # BaseEntity, QueryDslConfig, AuditorAware +│ ├── s3/ # S3Service, PresignedUrl +│ ├── util/ # MaskingUtil, CookieUtils, SecurityUtils +│ └── metrics/ # MetricsConfig +│ +└── security/ # Spring Security 설정 + ├── SecurityConfig.java + ├── CustomUserDetails.java + ├── CustomUserDetailsService.java + ├── JwtAuthenticationEntryPoint.java + └── JwtAccessDeniedHandler.java + +docker/ +├── docker-compose.yml # 전체 스택 (App, DB, Redis Cluster, Monitoring) +├── monitoring/ +│ ├── prometheus/ +│ │ ├── prometheus.yml +│ │ └── rules/auth-alerts.yml +│ ├── grafana/ +│ │ ├── provisioning/ +│ │ └── dashboards/ +│ └── alertmanager/ +│ └── alertmanager.yml +└── init-*.sh # Redis Cluster 초기화 스크립트 +``` + +
+ +--- + +## 📊 모니터링 구성 + +
+Grafana 대시보드 + +| 계층 | 대시보드 | 주요 메트릭 | +|:------------|:-------------------------|:----------------------------------------------------------------------------| +| **Service** | tt-service-overview | 요청 수, 에러율, 응답 시간 분포 | +| **Domain** | tt-auth-dashboard | `auth_login_total`, `auth_account_locked_total`, `auth_token_reissue_total` | +| | tt-email-dashboard | `email_sent_total`, `email_verification_total` | +| | tt-queue-dashboard | 대기열 상태, 처리량 | +| | tt-reservation-dashboard | 예매 생성, 좌석 선점/취소 | +| | tt-lottery-dashboard | 응모 수, 당첨 처리 | +| | tt-payment-dashboard | 결제 요청/완료/실패 | +| | tt-trade-dashboard | 거래 등록/완료 | +| **Infra** | tt-jvm-dashboard | 힙 메모리, GC, 스레드 풀 | +| | tt-database-dashboard | 커넥션 풀, 쿼리 성능 | +| | tt-redis-dashboard | 메모리, 커맨드/sec, 키 상태 | + +
+ +
+Alertmanager 규칙 + +```yaml +// 예시 +groups: + - name: auth-alerts + rules: + - alert: HighLoginFailureRate + expr: rate(auth_login_total{result="failure"}[5m]) > 0.1 + for: 2m + labels: + severity: warning + annotations: + summary: "로그인 실패율 증가" + + - alert: AccountLockDetected + expr: increase(auth_account_locked_total[5m]) > 0 + labels: + severity: critical + annotations: + summary: "계정 잠금 발생" +``` + +
+ +--- + +## 🧩 협업 규칙 + +
+펼쳐보기 + +### 코드 컨벤션 + +- **Naver Java Convention** 기반 +- IntelliJ IDEA 자동 서식 준수 + +### Git 브랜치 전략 + +- `main`: 프로덕션 +- `develop`: 개발 통합 +- `feature/*`: 기능 개발 +- 머지 조건: 최소 1명 리뷰 승인 + +
+ +--- + +### 🏷️ 네이밍 & 작성 규칙 + +
+펼쳐보기 + +#### 이슈(Issue) +- **제목 규칙**: `[타입] 작업내용` + - 예시: `[feat] 로그인 기능 추가` +- **본문**: 팀 템플릿에 맞춰 작성 + +#### PR(Pull Request) +- **제목 규칙**: `[타입] 작업내용` + - 예시: `[feat] 로그인 기능 추가` +- **본문**: 팀 템플릿에 맞춰 작성 +- **브랜치 보호 규칙**: `main`, `develop`은 보호 브랜치로 **최소 1명 리뷰 승인 후**에만 머지 + +#### 브랜치(Branch) +- **생성 기준**: `develop` 브랜치에서 생성 +- **명명 규칙**: `타입/작업내용` + - 예시: `feat/조회-기능-개발` + +#### Commit Message +- **형식**: `타입: 작업내용` + - 예시: `feat: 로그인 기능 추가` + +| 타입 | 의미 | +|---|---| +| `feat` | 새로운 기능 추가 | +| `fix` | 버그 수정 | +| `docs` | 문서 수정(README, 주석 등) | +| `refactor` | 코드 리팩토링(동작 변화 없음) | +| `test` | 테스트 코드 추가/수정 | + +
+ diff --git a/build.gradle b/build.gradle index 1c77fb408..a59fc2c9d 100644 --- a/build.gradle +++ b/build.gradle @@ -26,17 +26,35 @@ repositories { mavenCentral() } +// Spring Cloud 버전 관리 (Circuit Breaker용) +// Spring Boot 4.0.0은 아직 정식 Spring Cloud 버전이 없으므로 +// Resilience4j를 직접 사용 +ext { + set('resilience4jVersion', '2.2.0') +} + dependencies { // 웹이랑 벨리데이션 implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.aspectj:aspectjweaver:1.9.24' + + // Prometheus + implementation 'io.micrometer:micrometer-registry-prometheus' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' // jpa, redis implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.redisson:redisson-spring-boot-starter:3.23.5' + implementation 'org.redisson:redisson-spring-boot-starter:4.0.0' developmentOnly 'org.springframework.boot:spring-boot-devtools' + // Circuit Breaker (Resilience4j) - Spring Boot 4.0.0용 직접 의존성 + implementation "io.github.resilience4j:resilience4j-spring-boot3:${resilience4jVersion}" + implementation "io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}" + implementation "io.github.resilience4j:resilience4j-micrometer:${resilience4jVersion}" + // QueryDSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' @@ -79,7 +97,6 @@ dependencies { // AWS S3 implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:4.0.0-M1") implementation "io.awspring.cloud:spring-cloud-aws-starter-s3" - } tasks.withType(JavaCompile) { @@ -195,4 +212,4 @@ jacocoTestCoverageVerification { ]) })) } -} +} \ No newline at end of file diff --git a/docker/check-cluster-status.sh b/docker/check-cluster-status.sh new file mode 100644 index 000000000..58e70c30c --- /dev/null +++ b/docker/check-cluster-status.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Redis Cluster 상태 확인 스크립트 +# 사용법: bash docker/check-cluster-status.sh [비밀번호] +# 또는: cd docker && ./check-cluster-status.sh [비밀번호] + +REDIS_PASSWORD=${1:-"tt_redis_pass"} + +echo "============================================" +echo "Redis Cluster 상태 확인" +echo "============================================" +echo "비밀번호: ${REDIS_PASSWORD}" +echo "" + +echo "1. 클러스터 정보:" +echo "--------------------------------------------" +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 cluster info 2>&1 | grep -v "Warning:" +echo "" + +echo "2. 클러스터 노드 목록 (IP 주소 확인):" +echo "--------------------------------------------" +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 cluster nodes 2>&1 | grep -v "Warning:" +echo "" + +echo "3. 각 노드의 cluster-announce-ip 확인:" +echo "--------------------------------------------" +for i in {1..6}; do + PORT=$((7000 + i - 1)) + echo "redis-node-${i} (포트 ${PORT}):" + docker exec -i redis-node-${i} redis-cli -a ${REDIS_PASSWORD} -p ${PORT} CONFIG GET cluster-announce-ip 2>&1 | grep -v "Warning:" | tail -1 + echo "" +done + +echo "4. 클러스터 상태 요약:" +echo "--------------------------------------------" +CLUSTER_INFO=$(docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 cluster info 2>&1 | grep -v "Warning:") + +if echo "$CLUSTER_INFO" | grep -q "cluster_state:ok"; then + echo "✅ 클러스터 상태: OK" +else + echo "❌ 클러스터 상태: FAIL" +fi + +SLOTS=$(echo "$CLUSTER_INFO" | grep "cluster_slots_assigned" | cut -d: -f2 | tr -d ' ') +if [ "$SLOTS" = "16384" ]; then + echo "✅ 슬롯 할당: 완료 (16384/16384)" +else + echo "⚠️ 슬롯 할당: ${SLOTS}/16384" +fi + +NODES=$(echo "$CLUSTER_INFO" | grep "cluster_known_nodes" | cut -d: -f2 | tr -d ' ') +echo "📊 알려진 노드 수: ${NODES}" + +echo "" +echo "============================================" +echo "확인 완료" +echo "============================================" + diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3da034aea..c9a829466 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,14 +1,12 @@ -version: '3.8' services: - # 데이터베이스 설정 + # ==================== PostgreSQL ==================== postgres: image: postgres:16-alpine - container_name: tt_postgres restart: always environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER:-myuser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mypassword} + POSTGRES_DB: ${POSTGRES_DB:-tt_db} POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" ports: - "${POSTGRES_PORT:-5432}:5432" @@ -16,59 +14,501 @@ services: - postgres_data:/var/lib/postgresql/data networks: - common + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-myuser}" ] + interval: 10s + timeout: 5s + retries: 5 - # 레디스 설정 - redis: - image: redis:alpine - container_name: tt_redis - restart: always - command: redis-server --requirepass ${REDIS_PASSWORD} + # ==================== Redis Cluster ==================== + redis-node-1: + image: redis:7-alpine + command: + - redis-server + - --port + - "7000" + - --cluster-enabled + - "yes" + - --cluster-config-file + - nodes-1.conf + - --cluster-node-timeout + - "5000" + - --cluster-announce-ip + - redis-node-1 + - --cluster-announce-port + - "7000" + - --cluster-announce-bus-port + - "17000" + - --appendonly + - "yes" + - --save + - "900 1" + - --save + - "300 10" + - --save + - "60 10000" + - --requirepass + - ${REDIS_PASSWORD:-tt_redis_pass} + - --masterauth + - ${REDIS_PASSWORD:-tt_redis_pass} + ports: + - "7000:7000" + - "17000:17000" + volumes: + - redis-node-1-data:/data + networks: + - common + healthcheck: + test: [ "CMD", "redis-cli", "-p", "7000", "-a", "${REDIS_PASSWORD:-tt_redis_pass}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + + redis-node-2: + image: redis:7-alpine + command: + - redis-server + - --port + - "7001" + - --cluster-enabled + - "yes" + - --cluster-config-file + - nodes-2.conf + - --cluster-node-timeout + - "5000" + - --cluster-announce-ip + - redis-node-2 + - --cluster-announce-port + - "7001" + - --cluster-announce-bus-port + - "17001" + - --appendonly + - "yes" + - --save + - "900 1" + - --save + - "300 10" + - --save + - "60 10000" + - --requirepass + - ${REDIS_PASSWORD:-tt_redis_pass} + - --masterauth + - ${REDIS_PASSWORD:-tt_redis_pass} + ports: + - "7001:7001" + - "17001:17001" + volumes: + - redis-node-2-data:/data + networks: + - common + healthcheck: + test: [ "CMD", "redis-cli", "-p", "7001", "-a", "${REDIS_PASSWORD:-tt_redis_pass}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + + redis-node-3: + image: redis:7-alpine + command: + - redis-server + - --port + - "7002" + - --cluster-enabled + - "yes" + - --cluster-config-file + - nodes-3.conf + - --cluster-node-timeout + - "5000" + - --cluster-announce-ip + - redis-node-3 + - --cluster-announce-port + - "7002" + - --cluster-announce-bus-port + - "17002" + - --appendonly + - "yes" + - --save + - "900 1" + - --save + - "300 10" + - --save + - "60 10000" + - --requirepass + - ${REDIS_PASSWORD:-tt_redis_pass} + - --masterauth + - ${REDIS_PASSWORD:-tt_redis_pass} + ports: + - "7002:7002" + - "17002:17002" + volumes: + - redis-node-3-data:/data + networks: + - common + healthcheck: + test: [ "CMD", "redis-cli", "-p", "7002", "-a", "${REDIS_PASSWORD:-tt_redis_pass}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + + redis-node-4: + image: redis:7-alpine + command: + - redis-server + - --port + - "7003" + - --cluster-enabled + - "yes" + - --cluster-config-file + - nodes-4.conf + - --cluster-node-timeout + - "5000" + - --cluster-announce-ip + - redis-node-4 + - --cluster-announce-port + - "7003" + - --cluster-announce-bus-port + - "17003" + - --appendonly + - "yes" + - --save + - "900 1" + - --save + - "300 10" + - --save + - "60 10000" + - --requirepass + - ${REDIS_PASSWORD:-tt_redis_pass} + - --masterauth + - ${REDIS_PASSWORD:-tt_redis_pass} ports: - - "${REDIS_PORT:-6379}:6379" + - "7003:7003" + - "17003:17003" + volumes: + - redis-node-4-data:/data networks: - common + healthcheck: + test: [ "CMD", "redis-cli", "-p", "7003", "-a", "${REDIS_PASSWORD:-tt_redis_pass}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 - # 스프링 부트 애플리케이션 설정 + redis-node-5: + image: redis:7-alpine + command: + - redis-server + - --port + - "7004" + - --cluster-enabled + - "yes" + - --cluster-config-file + - nodes-5.conf + - --cluster-node-timeout + - "5000" + - --cluster-announce-ip + - redis-node-5 + - --cluster-announce-port + - "7004" + - --cluster-announce-bus-port + - "17004" + - --appendonly + - "yes" + - --save + - "900 1" + - --save + - "300 10" + - --save + - "60 10000" + - --requirepass + - ${REDIS_PASSWORD:-tt_redis_pass} + - --masterauth + - ${REDIS_PASSWORD:-tt_redis_pass} + ports: + - "7004:7004" + - "17004:17004" + volumes: + - redis-node-5-data:/data + networks: + - common + healthcheck: + test: [ "CMD", "redis-cli", "-p", "7004", "-a", "${REDIS_PASSWORD:-tt_redis_pass}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + + redis-node-6: + image: redis:7-alpine + command: + - redis-server + - --port + - "7005" + - --cluster-enabled + - "yes" + - --cluster-config-file + - nodes-6.conf + - --cluster-node-timeout + - "5000" + - --cluster-announce-ip + - redis-node-6 + - --cluster-announce-port + - "7005" + - --cluster-announce-bus-port + - "17005" + - --appendonly + - "yes" + - --save + - "900 1" + - --save + - "300 10" + - --save + - "60 10000" + - --requirepass + - ${REDIS_PASSWORD:-tt_redis_pass} + - --masterauth + - ${REDIS_PASSWORD:-tt_redis_pass} + ports: + - "7005:7005" + - "17005:17005" + volumes: + - redis-node-6-data:/data + networks: + - common + healthcheck: + test: [ "CMD", "redis-cli", "-p", "7005", "-a", "${REDIS_PASSWORD:-tt_redis_pass}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + + # ==================== Redis Cluster 초기화 ==================== + redis-cluster-init: + image: redis:7-alpine + depends_on: + redis-node-1: + condition: service_healthy + redis-node-2: + condition: service_healthy + redis-node-3: + condition: service_healthy + redis-node-4: + condition: service_healthy + redis-node-5: + condition: service_healthy + redis-node-6: + condition: service_healthy + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-tt_redis_pass} + volumes: + - ./init-cluster.sh:/init-cluster.sh:ro + command: sh /init-cluster.sh + networks: + - common + restart: "no" + + # ==================== Spring Boot Application ==================== app: image: ghcr.io/chehyeon-kim23/tt_backend:latest - container_name: tt_app restart: always depends_on: - - postgres - - redis + postgres: + condition: service_healthy + redis-cluster-init: + condition: service_completed_successfully ports: - "8080:8080" + networks: + - common + + environment: + # ===== Profile ===== + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod} + + # ===== DB (Spring 표준 키) ===== + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tt_db} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-myuser} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-mypassword} + + # ===== Redis (Spring 표준 키) ===== + SPRING_DATA_REDIS_CLUSTER_NODES: redis-node-1:7000,redis-node-2:7001,redis-node-3:7002,redis-node-4:7003,redis-node-5:7004,redis-node-6:7005 + SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD:-tt_redis_pass} + + # =====커스텀 키 ===== + POSTGRES_HOST: postgres + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_USER: ${POSTGRES_USER:-myuser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mypassword} + POSTGRES_DB: ${POSTGRES_DB:-tt_db} + + REDIS_MODE: cluster + REDIS_PASSWORD: ${REDIS_PASSWORD:-tt_redis_pass} + REDIS_CLUSTER_NODES: redis-node-1:7000,redis-node-2:7001,redis-node-3:7002,redis-node-4:7003,redis-node-5:7004,redis-node-6:7005 + + # ===== Mail ===== + MAIL_HOST: ${MAIL_HOST} + MAIL_PORT: ${MAIL_PORT} + MAIL_USERNAME: ${MAIL_USERNAME} + MAIL_PASSWORD: ${MAIL_PASSWORD} + MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS} + MAIL_FROM_NAME: ${MAIL_FROM_NAME} + + # ===== Spring Mail Properties ===== + SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE} + SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED} + + # ===== AWS / S3 ===== + AWS_REGION: ${AWS_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + + # ===== JWT ===== + JWT_SECRET: ${JWT_SECRET} + JWT_ACCESS_EXPIRATION: ${JWT_ACCESS_EXPIRATION} + JWT_REFRESH_EXPIRATION: ${JWT_REFRESH_EXPIRATION} + + # ===== Kakao OAuth ===== + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} + KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + KAKAO_ISSUER: ${KAKAO_ISSUER} + KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI} + KAKAO_TOKEN_URI: ${KAKAO_TOKEN_URI} + KAKAO_USER_INFO_URI: ${KAKAO_USER_INFO_URI} + + # ===== Slack ===== + SLACK_WEBHOOK_AUTH: ${SLACK_WEBHOOK_AUTH} + + # ===== JPA / Hibernate ===== + DDL_AUTO: ${DDL_AUTO} + SPRING_JPA_HIBERNATE_DDL_AUTO: ${SPRING_JPA_HIBERNATE_DDL_AUTO} + SPRING_JPA_PROPERTIES_HIBERNATE_HBM2DDL_HALT_ON_ERROR: ${SPRING_JPA_PROPERTIES_HIBERNATE_HBM2DDL_HALT_ON_ERROR} + + healthcheck: + test: [ "CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # ==================== Monitoring Stack ==================== + prometheus: + image: prom/prometheus:v3.8.1 + restart: unless-stopped + depends_on: + app: + condition: service_healthy + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/rules:/etc/prometheus/rules:ro + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=15d' + - '--web.enable-lifecycle' + networks: + - common + healthcheck: + test: [ "CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:9090/-/healthy" ] + interval: 30s + timeout: 10s + retries: 3 + + grafana: + image: grafana/grafana:12.3.0 + restart: unless-stopped + depends_on: + - prometheus + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin123!} + GF_USERS_ALLOW_SIGN_UP: "false" + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3001} + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + networks: + - common + healthcheck: + test: [ "CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1" ] + interval: 30s + timeout: 10s + retries: 3 + + alertmanager: + image: prom/alertmanager:v0.30.0 + restart: unless-stopped + depends_on: + - prometheus + ports: + - "9093:9093" + volumes: + - ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager_data:/alertmanager + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + networks: + - common + healthcheck: + test: [ "CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:9093/-/healthy" ] + interval: 30s + timeout: 10s + retries: 3 + + # NOTE: redis_exporter는 멀티 주소/클러스터를 완전 지원하지 않는 경우가 많아서 한 노드만 붙임 + redis-exporter: + image: oliver006/redis_exporter:v1.80.1 + restart: unless-stopped + depends_on: + redis-node-1: + condition: service_healthy + ports: + - "9121:9121" + environment: + REDIS_ADDR: redis://redis-node-1:7000 + REDIS_PASSWORD: ${REDIS_PASSWORD:-tt_redis_pass} + networks: + - common + # redis_exporter는 distroless 이미지라 shell 없음 - Prometheus scrape가 healthcheck 역할. + healthcheck: + test: [ "NONE" ] + interval: 30s + timeout: 10s + retries: 3 + + postgres-exporter: + image: prometheuscommunity/postgres-exporter:v0.15.0 + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + ports: + - "9187:9187" environment: - - SPRING_PROFILES_ACTIVE=prod - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_PORT=${POSTGRES_PORT:-5432} - - REDIS_PASSWORD=${REDIS_PASSWORD} - - REDIS_PORT=${REDIS_PORT:-6379} - - MAIL_HOST=${MAIL_HOST} - - MAIL_PORT=${MAIL_PORT} - - MAIL_USERNAME=${MAIL_USERNAME} - - MAIL_PASSWORD=${MAIL_PASSWORD} - - MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS} - - MAIL_FROM_NAME=${MAIL_FROM_NAME} - - JWT_SECRET=${JWT_SECRET} - - JWT_ACCESS_EXPIRATION=${JWT_ACCESS_EXPIRATION} - - JWT_REFRESH_EXPIRATION=${JWT_REFRESH_EXPIRATION} - - KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID} - - KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET} - - KAKAO_REDIRECT_URI=${KAKAO_REDIRECT_URI} - - KAKAO_TOKEN_URI=${KAKAO_TOKEN_URI} - - KAKAO_USER_INFO_URI=${KAKAO_USER_INFO_URI} - - KAKAO_ISSUER=${KAKAO_ISSUER} - - DDL_AUTO=${DDL_AUTO} - command: java -jar app.jar + DATA_SOURCE_NAME: postgresql://${POSTGRES_USER:-myuser}:${POSTGRES_PASSWORD:-mypassword}@postgres:5432/${POSTGRES_DB:-tt_db}?sslmode=disable networks: - common + healthcheck: + test: [ "CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:9187/metrics" ] + interval: 30s + timeout: 10s + retries: 3 networks: common: driver: bridge volumes: - postgres_data: \ No newline at end of file + postgres_data: + redis-node-1-data: + redis-node-2-data: + redis-node-3-data: + redis-node-4-data: + redis-node-5-data: + redis-node-6-data: + prometheus_data: + grafana_data: + alertmanager_data: diff --git a/docker/fix-cluster-connection.sh b/docker/fix-cluster-connection.sh new file mode 100644 index 000000000..258422307 --- /dev/null +++ b/docker/fix-cluster-connection.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +# Redis Cluster 노드 연결 복구 스크립트 +# 클러스터가 fail 상태일 때 노드들을 다시 연결합니다. +# 사용법: bash docker/fix-cluster-connection.sh tt_redis_pass + +REDIS_PASSWORD=${1:-"tt_redis_pass"} +CLUSTER_ANNOUNCE_IP=${CLUSTER_ANNOUNCE_IP:-"127.0.0.1"} + +echo "============================================" +echo "Redis Cluster 연결 복구" +echo "============================================" +echo "비밀번호: ${REDIS_PASSWORD}" +echo "" + +echo "1. 모든 노드가 실행 중인지 확인..." +echo "--------------------------------------------" +for i in {1..6}; do + if docker ps --format "{{.Names}}" | grep -q "redis-node-${i}"; then + echo "✅ redis-node-${i}: 실행 중" + else + echo "❌ redis-node-${i}: 실행 중이 아님" + exit 1 + fi +done +echo "" + +echo "2. 모든 노드 리셋..." +echo "--------------------------------------------" +for i in {1..6}; do + PORT=$((7000 + i - 1)) + echo "redis-node-${i} 리셋 중..." + docker exec -i redis-node-${i} redis-cli -a ${REDIS_PASSWORD} -p ${PORT} CLUSTER RESET HARD 2>&1 | grep -v "Warning:" || true +done +sleep 2 +echo "✅ 리셋 완료" +echo "" + +echo "3. Docker 내부 IP 주소 확인..." +echo "--------------------------------------------" +NODE1_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis-node-1) +NODE2_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis-node-2) +NODE3_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis-node-3) +NODE4_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis-node-4) +NODE5_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis-node-5) +NODE6_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis-node-6) + +echo " redis-node-1: ${NODE1_IP}" +echo " redis-node-2: ${NODE2_IP}" +echo " redis-node-3: ${NODE3_IP}" +echo " redis-node-4: ${NODE4_IP}" +echo " redis-node-5: ${NODE5_IP}" +echo " redis-node-6: ${NODE6_IP}" +echo "" + +echo "4. 노드 간 연결 설정..." +echo "--------------------------------------------" +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER MEET ${NODE2_IP} 7001 2>&1 | grep -v "Warning:" || true +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER MEET ${NODE3_IP} 7002 2>&1 | grep -v "Warning:" || true +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER MEET ${NODE4_IP} 7003 2>&1 | grep -v "Warning:" || true +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER MEET ${NODE5_IP} 7004 2>&1 | grep -v "Warning:" || true +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER MEET ${NODE6_IP} 7005 2>&1 | grep -v "Warning:" || true +echo "✅ 연결 설정 완료" +echo "" + +echo "5. 노드 연결 대기 (5초)..." +sleep 5 +echo "" + +echo "6. 슬롯 할당 (3 Master 노드에 16384 슬롯 분배)..." +echo "--------------------------------------------" +echo " 노드 1에 슬롯 0-5460 할당 중..." +SLOTS1="" +for i in $(seq 0 5460); do + SLOTS1="${SLOTS1} $i" +done +docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER ADDSLOTS ${SLOTS1} 2>&1 | grep -v "Warning:" || true + +echo " 노드 2에 슬롯 5461-10922 할당 중..." +SLOTS2="" +for i in $(seq 5461 10922); do + SLOTS2="${SLOTS2} $i" +done +docker exec -i redis-node-2 redis-cli -a ${REDIS_PASSWORD} -p 7001 CLUSTER ADDSLOTS ${SLOTS2} 2>&1 | grep -v "Warning:" || true + +echo " 노드 3에 슬롯 10923-16383 할당 중..." +SLOTS3="" +for i in $(seq 10923 16383); do + SLOTS3="${SLOTS3} $i" +done +docker exec -i redis-node-3 redis-cli -a ${REDIS_PASSWORD} -p 7002 CLUSTER ADDSLOTS ${SLOTS3} 2>&1 | grep -v "Warning:" || true +echo "✅ 슬롯 할당 완료" +echo "" + +echo "7. Replica 설정..." +echo "--------------------------------------------" +sleep 2 +NODE1_ID=$(docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER MYID 2>&1 | grep -v "Warning:" | tr -d '\r\n') +NODE2_ID=$(docker exec -i redis-node-2 redis-cli -a ${REDIS_PASSWORD} -p 7001 CLUSTER MYID 2>&1 | grep -v "Warning:" | tr -d '\r\n') +NODE3_ID=$(docker exec -i redis-node-3 redis-cli -a ${REDIS_PASSWORD} -p 7002 CLUSTER MYID 2>&1 | grep -v "Warning:" | tr -d '\r\n') + +echo " Master 노드 ID:" +echo " redis-node-1: ${NODE1_ID}" +echo " redis-node-2: ${NODE2_ID}" +echo " redis-node-3: ${NODE3_ID}" + +docker exec -i redis-node-4 redis-cli -a ${REDIS_PASSWORD} -p 7003 CLUSTER REPLICATE ${NODE1_ID} 2>&1 | grep -v "Warning:" || true +docker exec -i redis-node-5 redis-cli -a ${REDIS_PASSWORD} -p 7004 CLUSTER REPLICATE ${NODE2_ID} 2>&1 | grep -v "Warning:" || true +docker exec -i redis-node-6 redis-cli -a ${REDIS_PASSWORD} -p 7005 CLUSTER REPLICATE ${NODE3_ID} 2>&1 | grep -v "Warning:" || true +echo "✅ Replica 설정 완료" +echo "" + +echo "8. 클러스터 구성 대기 (5초)..." +sleep 5 +echo "" + +echo "9. 최종 상태 확인..." +echo "--------------------------------------------" +CLUSTER_STATE=$(docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER INFO 2>&1 | grep -v "Warning:" | grep "cluster_state" | cut -d: -f2 | tr -d '\r\n ') +CLUSTER_NODES=$(docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER INFO 2>&1 | grep -v "Warning:" | grep "cluster_known_nodes" | cut -d: -f2 | tr -d '\r\n ') +CLUSTER_SLOTS=$(docker exec -i redis-node-1 redis-cli -a ${REDIS_PASSWORD} -p 7000 CLUSTER INFO 2>&1 | grep -v "Warning:" | grep "cluster_slots_assigned" | cut -d: -f2 | tr -d '\r\n ') + +if [ "$CLUSTER_STATE" = "ok" ]; then + echo "✅ 클러스터 상태: OK" +else + echo "❌ 클러스터 상태: ${CLUSTER_STATE}" +fi + +if [ "$CLUSTER_SLOTS" = "16384" ]; then + echo "✅ 슬롯 할당: 완료 (16384/16384)" +else + echo "⚠️ 슬롯 할당: ${CLUSTER_SLOTS}/16384" +fi + +echo "📊 알려진 노드 수: ${CLUSTER_NODES}" +echo "" + +if [ "$CLUSTER_STATE" = "ok" ] && [ "$CLUSTER_SLOTS" = "16384" ]; then + echo "============================================" + echo "✅ 클러스터 복구 성공!" + echo "============================================" + echo "" + echo "다음 명령으로 상태를 확인하세요:" + echo "bash docker/check-cluster-status.sh" +else + echo "============================================" + echo "⚠️ 클러스터 복구가 완전하지 않습니다." + echo "============================================" + echo "" + echo "다음 명령으로 전체 재초기화를 시도하세요:" + echo "cd docker" + echo "./init-redis-cluster.sh ${REDIS_PASSWORD}" +fi + + diff --git a/docker/grafana/dashboards/1-service/tt-service-overview.json b/docker/grafana/dashboards/1-service/tt-service-overview.json new file mode 100644 index 000000000..a2cad5223 --- /dev/null +++ b/docker/grafana/dashboards/1-service/tt-service-overview.json @@ -0,0 +1,55 @@ +# ----- SLO Overview ----- + +# 전체 가용성 (%) +sum(rate(http_server_requests_seconds_count{status!~"5.."}[5m])) +/ +sum(rate(http_server_requests_seconds_count[5m])) +* 100 + +# 에러율 (%) +sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) +/ +sum(rate(http_server_requests_seconds_count[5m])) +* 100 + +# P95 응답시간 (초) +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# Error Budget (7일 SLO 99.9% 기준) +(1 - ((1 - sum(increase(http_server_requests_seconds_count{status!~"5.."}[7d])) / sum(increase(http_server_requests_seconds_count[7d]))) * 7 * 24 * 60 / 10.08)) * 100 + +# Error Budget (30일 SLO 99.9% 기준) +(1 - ((1 - sum(increase(http_server_requests_seconds_count{status!~"5.."}[30d])) / sum(increase(http_server_requests_seconds_count[30d]))) * 30 * 24 * 60 / 43.2)) * 100 + + +# ----- Traffic & Performance ----- + +# 처리량 (RPS) +sum(rate(http_server_requests_seconds_count[1m])) + +# P50 응답시간 +histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# P90 응답시간 +histogram_quantile(0.90, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + + +# ----- Business KPI ----- + +# 로그인 성공률 (%) +sum(rate(auth_login_total{result="success"}[5m])) / sum(rate(auth_login_total[5m])) * 100 + +# 이메일 인증 성공률 (%) +sum(rate(email_verification_total{result="success"}[5m])) / sum(rate(email_verification_total[5m])) * 100 + +# 잠긴 계정 수 +auth_locked_account_count + +# 보안 위협 탐지 (유형별) +sum(rate(security_threat_detected_total[5m])) by (type) diff --git a/docker/grafana/dashboards/2-domain/tt-auth-dashboard.json b/docker/grafana/dashboards/2-domain/tt-auth-dashboard.json new file mode 100644 index 000000000..d0af71f41 --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-auth-dashboard.json @@ -0,0 +1,67 @@ +# ----- 로그인 메트릭 ----- + +# 로그인 성공 추이 +sum(rate(auth_login_total{result="success"}[1m])) + +# 로그인 실패 추이 +sum(rate(auth_login_total{result="failure"}[1m])) + +# 로그인 성공률 (%) +sum(rate(auth_login_total{result="success"}[5m])) / sum(rate(auth_login_total[5m])) * 100 + +# Provider별 로그인 (EMAIL) +sum(rate(auth_login_total{provider="EMAIL"}[1m])) by (result) + +# Provider별 로그인 (KAKAO) +sum(rate(auth_login_total{provider="KAKAO"}[1m])) by (result) + +# 로그인 실패 사유별 분포 +sum(rate(auth_login_failure_reason_total[5m])) by (reason) + + +# ----- 계정 보안 ----- + +# 현재 잠긴 계정 수 +auth_locked_account_count + +# 계정 잠금 발생 추이 +sum(rate(auth_account_locked_total[5m])) + +# 계정 잠금 24시간 누적 +sum(increase(auth_account_locked_total[24h])) + + +# ----- 보안 위협 ----- + +# 보안 위협 탐지 (유형별) +sum(rate(security_threat_detected_total[5m])) by (type) + +# 보안 위협 탐지 (심각도별) +sum(rate(security_threat_detected_total[5m])) by (severity) + +# Rate Limit 트리거 (엔드포인트별) +sum(rate(security_rate_limit_triggered_total[5m])) by (endpoint) + +# 24시간 보안 위협 누적 +sum(increase(security_threat_detected_total[24h])) by (type) + + +# ----- 토큰 관리 ----- + +# 토큰 재발급 추이 +sum(rate(auth_token_reissue_total[5m])) + +# 로그아웃 추이 +sum(rate(auth_logout_total[5m])) + + +# ----- Auth API 성능 ----- + +# Auth API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/auth.*"}[5m])) by (le)) + +# Auth API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/auth.*", status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/auth.*"}[5m])) * 100 + +# Auth API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/auth.*"}[1m])) diff --git a/docker/grafana/dashboards/2-domain/tt-email-dashboard.json b/docker/grafana/dashboards/2-domain/tt-email-dashboard.json new file mode 100644 index 000000000..4e8375306 --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-email-dashboard.json @@ -0,0 +1,40 @@ +# ----- 이메일 발송 ----- + +# 이메일 발송 성공 추이 +sum(rate(email_sent_total{result="success"}[1m])) + +# 이메일 발송 실패 추이 +sum(rate(email_sent_total{result="failure"}[1m])) + +# 이메일 발송 성공률 (%) +sum(rate(email_sent_total{result="success"}[5m])) / sum(rate(email_sent_total[5m])) * 100 + +# 24시간 이메일 발송 수 +sum(increase(email_sent_total[24h])) by (result) + + +# ----- 이메일 인증 ----- + +# 이메일 인증 성공 추이 +sum(rate(email_verification_total{result="success"}[1m])) + +# 이메일 인증 실패 추이 +sum(rate(email_verification_total{result="failure"}[1m])) + +# 이메일 인증 성공률 (%) +sum(rate(email_verification_total{result="success"}[5m])) / sum(rate(email_verification_total[5m])) * 100 + +# 24시간 인증 시도 수 +sum(increase(email_verification_total[24h])) by (result) + + +# ----- Email API 성능 ----- + +# Email API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/email.*"}[5m])) by (le)) + +# Email API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/email.*", status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/email.*"}[5m])) * 100 + +# Email API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/email.*"}[1m])) diff --git a/docker/grafana/dashboards/2-domain/tt-lottery-dashboard.json b/docker/grafana/dashboards/2-domain/tt-lottery-dashboard.json new file mode 100644 index 000000000..92cb35507 --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-lottery-dashboard.json @@ -0,0 +1,75 @@ +# ----- 추첨 현황 (LotteryMetrics 기반) ----- + +# 추첨 응모 추이 +sum(rate(lottery_entry_total[ + 1m +])) by (performance_id) + +# 추첨 응모 수량 추이 +sum(rate(lottery_entry_quantity_total[ + 1m +])) by (performance_id) + +# 추첨 당첨 추이 +sum(rate(lottery_draw_total{result="won" +}[ + 1m +])) by (performance_id) + +# 추첨 낙첨 추이 +sum(rate(lottery_draw_total{result="lost" +}[ + 1m +])) by (performance_id) + +# 추첨 당첨률 +sum(rate(lottery_draw_total{result="won" +}[ + 5m +])) / sum(rate(lottery_draw_total[ + 5m +])) * 100 + +# 당첨자 결제 완료 추이 +sum(rate(lottery_winner_payment_total{status="completed" +}[ + 1m +])) by (performance_id) + +# 당첨자 결제 만료 추이 +sum(rate(lottery_winner_payment_total{status="expired" +}[ + 1m +])) by (performance_id) + +# 당첨자 결제 전환율 +sum(rate(lottery_winner_payment_total{status="completed" +}[ + 1h +])) / sum(rate(lottery_winner_payment_total[ + 1h +])) * 100 + + +# ----- Lottery API 성능 ----- + +# Lottery API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/lottery.*" +}[ + 5m +])) by (le)) + +# Lottery API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/lottery.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/lottery.*" +}[ + 5m +])) * 100 + +# Lottery API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/lottery.*" +}[ + 1m +])) diff --git a/docker/grafana/dashboards/2-domain/tt-payment-dashboard.json b/docker/grafana/dashboards/2-domain/tt-payment-dashboard.json new file mode 100644 index 000000000..16a49bfea --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-payment-dashboard.json @@ -0,0 +1,97 @@ +# ----- 결제 현황 (PaymentMetrics 기반) ----- + +# 결제 성공 추이 +sum(rate(payment_total{result="success" +}[ + 1m +])) + +# 결제 실패 추이 +sum(rate(payment_total{result="failure" +}[ + 1m +])) + +# 결제 성공률 +sum(rate(payment_total{result="success" +}[ + 5m +])) / sum(rate(payment_total[ + 5m +])) * 100 + +# 도메인 타입별 결제 성공 +sum(rate(payment_total{result="success" +}[ + 5m +])) by (domain_type) + +# 결제 수단별 분포 +sum(rate(payment_total{result="success" +}[ + 5m +])) by (method) + +# 결제 실패 사유별 분포 +sum(rate(payment_total{result="failure" +}[ + 5m +])) by (reason) + +# 환불 추이 +sum(rate(payment_refund_total[ + 1m +])) + +# 환불 금액 누적 +sum(increase(payment_refund_amount_total[ + 24h +])) by (domain_type) + +# 결제 금액 분포 P50 +payment_amount{quantile="0.5" +} + +# 결제 금액 분포 P95 +payment_amount{quantile="0.95" +} + +# 결제 처리 시간 P95 +histogram_quantile(0.95, sum(rate(payment_process_duration_seconds_bucket[ + 5m +])) by (le)) + +# 결제 처리 시간 P99 +histogram_quantile(0.99, sum(rate(payment_process_duration_seconds_bucket[ + 5m +])) by (le)) + + +# ----- Payment API 성능 ----- + +# Payment API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/payments.*" +}[ + 5m +])) by (le)) + +# Payment API P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/payments.*" +}[ + 5m +])) by (le)) + +# Payment API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/payments.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/payments.*" +}[ + 5m +])) * 100 + +# Payment API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/payments.*" +}[ + 1m +])) diff --git a/docker/grafana/dashboards/2-domain/tt-performance-dashboard.json b/docker/grafana/dashboards/2-domain/tt-performance-dashboard.json new file mode 100644 index 000000000..eeeae8fa4 --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-performance-dashboard.json @@ -0,0 +1,55 @@ +# ----- 공연 현황 (PerformanceMetrics 기반) ----- + +# 공연 상세 조회 추이 (공연별) +sum(rate(performance_view_total[ + 1m +])) by (performance_id) + +# 공연 상세 조회 전체 RPS +sum(rate(performance_view_total[ + 1m +])) + +# 회차 조회 추이 (회차별) +sum(rate(schedule_view_total[ + 1m +])) by (schedule_id) + +# 좌석 선택 추이 (등급별) +sum(rate(seat_selection_total[ + 1m +])) by (schedule_id, grade) + +# 좌석 등급별 선택 분포 +sum(rate(seat_selection_total[ + 5m +])) by (grade) + +# 인기 공연 TOP 10 (조회수 기준) +topk(10, sum(increase(performance_view_total[ + 24h +])) by (performance_id)) + + +# ----- Performance API 성능 ----- + +# Performance API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/performances.*" +}[ + 5m +])) by (le)) + +# Performance API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/performances.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/performances.*" +}[ + 5m +])) * 100 + +# Performance API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/performances.*" +}[ + 1m +])) diff --git a/docker/grafana/dashboards/2-domain/tt-queue-dashboard.json b/docker/grafana/dashboards/2-domain/tt-queue-dashboard.json new file mode 100644 index 000000000..43786ae48 --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-queue-dashboard.json @@ -0,0 +1,78 @@ +# ----- 대기열 현황 (QueueMetrics 기반) ----- + +# 현재 대기 중인 사용자 수 (queue별) +queue_waiting_count + +# 현재 진입 가능한 사용자 수 (queue별) +queue_enterable_count + +# 대기열 진입 추이 +sum(rate(queue_enter_total[ + 1m +])) by (queue_id) + +# 대기열 이탈 추이 (사유별) +sum(rate(queue_exit_total[ + 1m +])) by (queue_id, reason) + +# 대기열 승격(WAITING → ENTERABLE) 추이 +sum(rate(queue_enterable_total[ + 1m +])) by (queue_id) + +# 대기열 입장 완료 추이 +sum(rate(queue_complete_total[ + 1m +])) by (queue_id) + +# 대기열 이탈률 (만료 비율) +sum(rate(queue_exit_total{reason="EXPIRED" +}[ + 5m +])) / sum(rate(queue_exit_total[ + 5m +])) * 100 + +# 대기열 완료율 (완료 비율) +sum(rate(queue_exit_total{reason="COMPLETED" +}[ + 5m +])) / sum(rate(queue_exit_total[ + 5m +])) * 100 + +# 전체 대기열 진입 RPS +sum(rate(queue_enter_total[ + 1m +])) + + +# ----- Queue API 성능 ----- + +# Queue API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/queues.*" +}[ + 5m +])) by (le)) + +# Queue API P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/queues.*" +}[ + 5m +])) by (le)) + +# Queue API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/queues.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/queues.*" +}[ + 5m +])) * 100 + +# Queue API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/queues.*" +}[ + 1m +])) diff --git a/docker/grafana/dashboards/2-domain/tt-reservation-dashboard.json b/docker/grafana/dashboards/2-domain/tt-reservation-dashboard.json new file mode 100644 index 000000000..dec7d8d7d --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-reservation-dashboard.json @@ -0,0 +1,95 @@ +# ----- 예약 현황 (ReservationMetrics 기반) ----- + +# 예약 생성 추이 +sum(rate(reservation_total{action="created" +}[ + 1m +])) + +# 예약 완료 추이 +sum(rate(reservation_total{action="completed" +}[ + 1m +])) + +# 예약 실패 추이 +sum(rate(reservation_total{action="failed" +}[ + 1m +])) + +# 예약 만료 추이 +sum(rate(reservation_total{action="expired" +}[ + 1m +])) + +# 예약 취소 추이 +sum(rate(reservation_total{action="cancelled" +}[ + 1m +])) + +# 예약 전환율 (완료/생성) +sum(rate(reservation_total{action="completed" +}[ + 1h +])) / sum(rate(reservation_total{action="created" +}[ + 1h +])) * 100 + +# 현재 PENDING 상태 예약 수 +reservation_pending_count + +# 예약 생성 처리 시간 P95 +histogram_quantile(0.95, sum(rate(reservation_creation_duration_seconds_bucket[ + 5m +])) by (le)) + +# 예약 생성 처리 시간 P99 +histogram_quantile(0.99, sum(rate(reservation_creation_duration_seconds_bucket[ + 5m +])) by (le)) + + +# ----- Reservation API 성능 ----- + +# Reservation API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/reservations.*" +}[ + 5m +])) by (le)) + +# Reservation API P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/reservations.*" +}[ + 5m +])) by (le)) + +# Reservation API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 5m +])) * 100 + +# Reservation API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 1m +])) + +# Reservation API HTTP 상태별 분포 +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 5m +])) by (status) + +# Reservation API 메서드별 RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 1m +])) by (method) diff --git a/docker/grafana/dashboards/2-domain/tt-trade-dashboard.json b/docker/grafana/dashboards/2-domain/tt-trade-dashboard.json new file mode 100644 index 000000000..3a8166ad5 --- /dev/null +++ b/docker/grafana/dashboards/2-domain/tt-trade-dashboard.json @@ -0,0 +1,77 @@ +# ----- 거래 현황 (TradeMetrics 기반) ----- + +# 거래 생성 추이 (유형별) +sum(rate(trade_total{action="created" +}[ + 1m +])) by (type) + +# 거래 완료 추이 (유형별) +sum(rate(trade_total{action="completed" +}[ + 1m +])) by (type) + +# 거래 취소 추이 (유형별) +sum(rate(trade_total{action="cancelled" +}[ + 1m +])) by (type) + +# 거래 성공률 (유형별) +sum(rate(trade_total{action="completed" +}[ + 1h +])) by (type) / sum(rate(trade_total{action="created" +}[ + 1h +])) by (type) * 100 + +# 거래 요청 추이 (유형별) +sum(rate(trade_request_total[ + 1m +])) by (type) + +# 현재 활성 거래 수 +trade_active_count + +# 양도 금액 분포 P50 +trade_price{quantile="0.5" +} + +# 양도 금액 분포 P95 +trade_price{quantile="0.95" +} + +# 양도 vs 교환 비율 +sum(rate(trade_total{action="created", type="TRANSFER" +}[ + 1h +])) / sum(rate(trade_total{action="created" +}[ + 1h +])) * 100 + + +# ----- Trade API 성능 ----- + +# Trade API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/trades.*" +}[ + 5m +])) by (le)) + +# Trade API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/trades.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/trades.*" +}[ + 5m +])) * 100 + +# Trade API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/trades.*" +}[ + 1m +])) diff --git a/docker/grafana/dashboards/3-infra/tt-container-dashboard.json b/docker/grafana/dashboards/3-infra/tt-container-dashboard.json new file mode 100644 index 000000000..8f9f94bb5 --- /dev/null +++ b/docker/grafana/dashboards/3-infra/tt-container-dashboard.json @@ -0,0 +1,67 @@ +# ----- Container CPU ----- + +# CPU 사용률 (%) - Pod별 +sum(rate(container_cpu_usage_seconds_total{container="b2st-backend"}[5m])) by (pod) * 100 + +# CPU 사용량 (cores) +sum(rate(container_cpu_usage_seconds_total{container="b2st-backend"}[5m])) by (pod) + +# CPU 요청량 대비 사용률 +sum(rate(container_cpu_usage_seconds_total{container="b2st-backend"}[5m])) by (pod) / sum(kube_pod_container_resource_requests{resource="cpu", container="b2st-backend"}) by (pod) * 100 + + +# ----- Container 메모리 ----- + +# 메모리 사용량 (bytes) +container_memory_usage_bytes{container="b2st-backend"} + +# 메모리 제한 대비 사용률 (%) +container_memory_usage_bytes{container="b2st-backend"} / container_spec_memory_limit_bytes{container="b2st-backend"} * 100 + +# 메모리 워킹 셋 +container_memory_working_set_bytes{container="b2st-backend"} + + +# ----- Container 네트워크 ----- + +# 네트워크 수신 (bytes/s) +rate(container_network_receive_bytes_total{pod=~"b2st.*"}[1m]) + +# 네트워크 송신 (bytes/s) +rate(container_network_transmit_bytes_total{pod=~"b2st.*"}[1m]) + +# 네트워크 수신 패킷 (/s) +rate(container_network_receive_packets_total{pod=~"b2st.*"}[1m]) + +# 네트워크 송신 패킷 (/s) +rate(container_network_transmit_packets_total{pod=~"b2st.*"}[1m]) + +# 네트워크 에러 (수신) +rate(container_network_receive_errors_total{pod=~"b2st.*"}[5m]) + +# 네트워크 에러 (송신) +rate(container_network_transmit_errors_total{pod=~"b2st.*"}[5m]) + + +# ----- Container 디스크 I/O ----- + +# 디스크 읽기 (bytes/s) +rate(container_fs_reads_bytes_total{container="b2st-backend"}[1m]) + +# 디스크 쓰기 (bytes/s) +rate(container_fs_writes_bytes_total{container="b2st-backend"}[1m]) + + +# ----- Pod 상태 ----- + +# Pod 재시작 횟수 +kube_pod_container_status_restarts_total{container="b2st-backend"} + +# Pod 상태 +kube_pod_status_phase{pod=~"b2st.*"} + +# Ready 상태 Pod 수 +sum(kube_pod_status_ready{pod=~"b2st.*", condition="true"}) + +# Pending Pod 수 +sum(kube_pod_status_phase{pod=~"b2st.*", phase="Pending"}) diff --git a/docker/grafana/dashboards/3-infra/tt-database-dashboard.json b/docker/grafana/dashboards/3-infra/tt-database-dashboard.json new file mode 100644 index 000000000..a8e959139 --- /dev/null +++ b/docker/grafana/dashboards/3-infra/tt-database-dashboard.json @@ -0,0 +1,43 @@ +# ----- HikariCP 커넥션 풀 ----- + +# 활성 커넥션 수 +hikaricp_connections_active + +# 대기 중인 커넥션 요청 수 +hikaricp_connections_pending + +# 유휴 커넥션 수 +hikaricp_connections_idle + +# 전체 커넥션 수 +hikaricp_connections + +# 최대 커넥션 수 +hikaricp_connections_max + +# 최소 커넥션 수 +hikaricp_connections_min + +# 커넥션 풀 사용률 (%) +hikaricp_connections_active / hikaricp_connections_max * 100 + + +# ----- 커넥션 획득 ----- + +# 커넥션 획득 평균 시간 (초) +rate(hikaricp_connections_acquire_seconds_sum[5m]) / rate(hikaricp_connections_acquire_seconds_count[5m]) + +# 커넥션 생성 평균 시간 (초) +rate(hikaricp_connections_creation_seconds_sum[5m]) / rate(hikaricp_connections_creation_seconds_count[5m]) + +# 커넥션 사용 평균 시간 (초) +rate(hikaricp_connections_usage_seconds_sum[5m]) / rate(hikaricp_connections_usage_seconds_count[5m]) + + +# ----- 커넥션 문제 ----- + +# 커넥션 타임아웃 발생 횟수 (5분간) +increase(hikaricp_connections_timeout_total[5m]) + +# 커넥션 타임아웃 발생률 +rate(hikaricp_connections_timeout_total[5m]) diff --git a/docker/grafana/dashboards/3-infra/tt-jvm-dashboard.json b/docker/grafana/dashboards/3-infra/tt-jvm-dashboard.json new file mode 100644 index 000000000..fd29f291f --- /dev/null +++ b/docker/grafana/dashboards/3-infra/tt-jvm-dashboard.json @@ -0,0 +1,73 @@ +# ----- 힙 메모리 ----- + +# 힙 메모리 사용률 (%) +jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100 + +# 힙 메모리 사용량 (bytes) +jvm_memory_used_bytes{area="heap"} + +# 힙 메모리 최대 (bytes) +jvm_memory_max_bytes{area="heap"} + +# 힙 메모리 커밋 (bytes) +jvm_memory_committed_bytes{area="heap"} + + +# ----- Non-Heap 메모리 ----- + +# Non-Heap 메모리 사용량 +jvm_memory_used_bytes{area="nonheap"} + +# Metaspace 사용량 +jvm_memory_used_bytes{id="Metaspace"} + + +# ----- GC ----- + +# GC 일시정지 시간 (초/분) +rate(jvm_gc_pause_seconds_sum[5m]) + +# GC 발생 횟수 (/분) +rate(jvm_gc_pause_seconds_count[5m]) + +# GC 일시정지 시간 누적 (5분) +increase(jvm_gc_pause_seconds_sum[5m]) + +# GC Collector별 횟수 +increase(jvm_gc_pause_seconds_count[5m]) by (gc, cause) + + +# ----- 스레드 ----- + +# 활성 스레드 수 +jvm_threads_live_threads + +# 피크 스레드 수 +jvm_threads_peak_threads + +# 데몬 스레드 수 +jvm_threads_daemon_threads + +# 스레드 상태별 분포 +jvm_threads_states_threads + + +# ----- 클래스 로딩 ----- + +# 로딩된 클래스 수 +jvm_classes_loaded_classes + +# 언로드된 클래스 수 +jvm_classes_unloaded_classes_total + + +# ----- CPU ----- + +# 프로세스 CPU 사용률 +process_cpu_usage * 100 + +# 시스템 CPU 사용률 +system_cpu_usage * 100 + +# CPU 수 +system_cpu_count diff --git a/docker/grafana/dashboards/3-infra/tt-redis-dashboard.json b/docker/grafana/dashboards/3-infra/tt-redis-dashboard.json new file mode 100644 index 000000000..7388c1bb4 --- /dev/null +++ b/docker/grafana/dashboards/3-infra/tt-redis-dashboard.json @@ -0,0 +1,75 @@ +# redis_exporter 필요 + +# ----- Redis 메모리 ----- + +# Redis 메모리 사용량 +redis_memory_used_bytes + +# Redis 메모리 최대 +redis_memory_max_bytes + +# Redis 메모리 사용률 (%) +redis_memory_used_bytes / redis_memory_max_bytes * 100 + +# Redis RSS 메모리 +redis_memory_used_rss_bytes + + +# ----- Redis 연결 ----- + +# 연결된 클라이언트 수 +redis_connected_clients + +# 차단된 클라이언트 수 +redis_blocked_clients + +# 거부된 연결 수 (5분간) +increase(redis_rejected_connections_total[5m]) + + +# ----- Redis 명령 ----- + +# 초당 명령 수 +rate(redis_commands_total[1m]) + +# 명령별 초당 수 +rate(redis_commands_total[1m]) by (cmd) + +# 초당 처리된 명령 수 (redis info) +redis_instantaneous_ops_per_sec + + +# ----- Redis 키 ----- + +# 전체 키 수 +redis_db_keys + +# 만료 대기 중인 키 수 +redis_db_keys_expiring + +# 초당 만료된 키 수 +rate(redis_expired_keys_total[1m]) + +# 초당 퇴치된 키 수 +rate(redis_evicted_keys_total[1m]) + + +# ----- Redis 히트율 ----- + +# 키스페이스 히트율 (%) +rate(redis_keyspace_hits_total[5m]) / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])) * 100 + +# 키스페이스 히트 수 +rate(redis_keyspace_hits_total[1m]) + +# 키스페이스 미스 수 +rate(redis_keyspace_misses_total[1m]) + + +# ----- Redis 복제 ----- + +# 연결된 슬레이브 수 +redis_connected_slaves + +# 복제 지연 (초) +redis_replica_offset diff --git a/docker/init-cluster.sh b/docker/init-cluster.sh new file mode 100644 index 000000000..9a83bdc03 --- /dev/null +++ b/docker/init-cluster.sh @@ -0,0 +1,73 @@ +#!/bin/sh +set -e + +# Redis Cluster 초기화 스크립트 (컨테이너 내부 실행용) +# redis-cli만 사용하여 컨테이너 내부에서 실행 + +REDIS_PASSWORD="${REDIS_PASSWORD:-tt_redis_pass}" +MAX_WAIT=60 + +echo "============================================" +echo "Redis Cluster 자동 초기화" +echo "============================================" +echo "" + +# 모든 Redis 노드가 준비될 때까지 대기 +echo "[1] Redis 노드 준비 대기..." +for node_num in 1 2 3 4 5 6; do + port=$((7000 + node_num - 1)) + node_name="redis-node-${node_num}" + + echo " ${node_name} (port ${port}) 대기 중..." + wait_count=0 + while [ $wait_count -lt $MAX_WAIT ]; do + if redis-cli -h "$node_name" -p "$port" -a "$REDIS_PASSWORD" ping >/dev/null 2>&1; then + echo " ✅ ${node_name} 준비 완료" + break + fi + sleep 1 + wait_count=$((wait_count + 1)) + done + + if [ $wait_count -eq $MAX_WAIT ]; then + echo "❌ ${node_name}가 준비되지 않았습니다 (타임아웃)" + exit 1 + fi +done + +echo "" +echo "[2] 클러스터 상태 확인..." + +# 클러스터가 이미 구성되어 있는지 확인 +CLUSTER_INFO=$(redis-cli -h redis-node-1 -p 7000 -a "$REDIS_PASSWORD" cluster info 2>/dev/null || echo "") + +if echo "$CLUSTER_INFO" | grep -q "cluster_state:ok"; then + echo " ✅ 클러스터가 이미 구성되어 있습니다." + echo "" + echo "============================================" + echo "✅ Redis Cluster 준비 완료 (스킵)" + echo "============================================" + redis-cli -h redis-node-1 -p 7000 -a "$REDIS_PASSWORD" cluster info | grep -E "cluster_state|cluster_slots_assigned|cluster_known_nodes" || true + exit 0 +fi + +echo " 클러스터가 아직 구성되지 않았습니다. 초기화를 진행합니다..." +echo "" + +echo "[3] 클러스터 생성..." +redis-cli --cluster create \ + redis-node-1:7000 redis-node-2:7001 redis-node-3:7002 \ + redis-node-4:7003 redis-node-5:7004 redis-node-6:7005 \ + --cluster-replicas 1 \ + --cluster-yes \ + -a "$REDIS_PASSWORD" + +echo "" +echo "[4] 클러스터 상태 확인..." +sleep 2 +redis-cli -h redis-node-1 -p 7000 -a "$REDIS_PASSWORD" cluster info | grep -E "cluster_state|cluster_slots_assigned|cluster_known_nodes" || true + +echo "" +echo "============================================" +echo "✅ Redis Cluster 초기화 완료" +echo "============================================" diff --git a/docker/init-redis-cluster.sh b/docker/init-redis-cluster.sh new file mode 100644 index 000000000..791e805bc --- /dev/null +++ b/docker/init-redis-cluster.sh @@ -0,0 +1,197 @@ +#!/bin/bash +set -euo pipefail + +# Redis Cluster 초기화 스크립트 (장애 복구 / 수동 초기화 전용) +# +# ⚠️ 운영 가이드: +# - 정상 배포 시에는 사용하지 마세요! docker compose up -d만 사용하면 됩니다. +# - redis-cluster-init 서비스가 자동으로 초기화하므로 중복 실행을 방지합니다. +# - 이 스크립트는 장애 복구나 수동 개입이 필요할 때만 사용하세요. +# +# 사용 예시: +# 1) 장애 복구: 클러스터 재초기화 (통합 compose 자동 탐색) +# bash docker/init-redis-cluster.sh +# +# 2) 비밀번호 지정 +# bash docker/init-redis-cluster.sh "tt_redis_pass" +# +# 3) 초기화 전에 완전 초기화(컨테이너+볼륨 삭제)까지 하고 싶을 때 +# RESET=1 bash docker/init-redis-cluster.sh +# +# 4) 특정 compose 파일 지정 (기본 자동 탐색 대신) +# COMPOSE_FILE=docker-compose.redis-cluster.yml bash docker/init-redis-cluster.sh +# +# 5) 프로덕션에서 announce 설정 (분리된 compose 전용, 통합 compose는 자동 비활성화) +# ⚠️ 통합 compose 사용 시 APPLY_ANNOUNCE는 자동으로 비활성화됨 +# CLUSTER_ANNOUNCE_IP=10.0.0.12 APPLY_ANNOUNCE=1 bash docker/init-redis-cluster.sh + +# 비밀번호: 인자 > 환경변수 > 기본값 +REDIS_PASSWORD="${1:-${REDIS_PASSWORD:-tt_redis_pass}}" + +# 0이면 안전 모드(기본). 1이면 down -v 수행 (데이터/볼륨 삭제) +RESET="${RESET:-0}" + +# announce 설정 적용 여부 (기본 0) +APPLY_ANNOUNCE="${APPLY_ANNOUNCE:-0}" +CLUSTER_ANNOUNCE_IP="${CLUSTER_ANNOUNCE_IP:-}" + +# 스크립트 위치 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# compose 파일 자동 탐색 또는 환경 변수 지정 +# 환경 변수 COMPOSE_FILE이 지정되어 있으면 그것을 사용 +if [ -n "${COMPOSE_FILE:-}" ]; then + if [ ! -f "$COMPOSE_FILE" ]; then + echo "❌ 오류: 지정된 compose 파일이 없습니다: $COMPOSE_FILE" + exit 1 + fi + echo "ℹ️ 환경 변수로 compose 파일 지정됨: $COMPOSE_FILE" +elif [ -f "$PROJECT_ROOT/docker-compose.yml" ]; then + # docker-compose.yml 우선 (통합 구성) + COMPOSE_FILE="$PROJECT_ROOT/docker-compose.yml" +elif [ -f "$PROJECT_ROOT/docker/docker-compose.redis-cluster.yml" ]; then + COMPOSE_FILE="$PROJECT_ROOT/docker/docker-compose.redis-cluster.yml" +elif [ -f "$PROJECT_ROOT/docker-compose.redis-cluster.yml" ]; then + COMPOSE_FILE="$PROJECT_ROOT/docker-compose.redis-cluster.yml" +elif [ -f "$SCRIPT_DIR/docker-compose.redis-cluster.yml" ]; then + COMPOSE_FILE="$SCRIPT_DIR/docker-compose.redis-cluster.yml" +else + echo "❌ 오류: compose 파일을 찾을 수 없습니다." + echo " 확인 경로:" + echo " - $PROJECT_ROOT/docker-compose.yml (통합 구성)" + echo " - $PROJECT_ROOT/docker/docker-compose.redis-cluster.yml" + echo " - $PROJECT_ROOT/docker-compose.redis-cluster.yml" + echo " - $SCRIPT_DIR/docker-compose.redis-cluster.yml" + echo "" + echo " 또는 환경 변수로 지정:" + echo " COMPOSE_FILE=/path/to/compose.yml bash docker/init-redis-cluster.sh" + exit 1 +fi + +cd "$PROJECT_ROOT" + +# 통합 compose 사용 시 announce 설정 강제 비활성화 +# (통합 compose에 이미 --cluster-announce-ip가 서비스명으로 설정되어 있음) +if echo "$COMPOSE_FILE" | grep -q "docker-compose.yml$" && [ "$APPLY_ANNOUNCE" = "1" ]; then + echo "⚠️ 경고: 통합 compose 파일에서는 APPLY_ANNOUNCE를 사용할 수 없습니다." + echo " 통합 compose에 이미 --cluster-announce-ip가 서비스명으로 설정되어 있습니다." + echo " APPLY_ANNOUNCE를 자동으로 비활성화합니다." + APPLY_ANNOUNCE=0 +fi + +echo "============================================" +echo "Redis Cluster 초기화 (안전 버전)" +echo "============================================" +echo "Compose 파일: $COMPOSE_FILE" +echo "RESET(볼륨삭제): $RESET" +echo "APPLY_ANNOUNCE: $APPLY_ANNOUNCE" +echo "CLUSTER_ANNOUNCE_IP: ${CLUSTER_ANNOUNCE_IP:-}" +echo "" + +export REDIS_PASSWORD="$REDIS_PASSWORD" + +# RESET=1일 때만 파괴적 초기화 수행 +if [ "$RESET" = "1" ]; then + echo "[0] 기존 컨테이너/볼륨 정리 (RESET=1)..." + # docker-compose.yml의 경우 Redis 노드만 정리 (다른 서비스 보존) + if echo "$COMPOSE_FILE" | grep -q "docker-compose.yml$"; then + echo " 통합 구성 파일 감지: Redis 노드만 정리합니다 (PostgreSQL/App 유지)..." + docker compose -f "$COMPOSE_FILE" stop redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5 redis-node-6 || true + docker compose -f "$COMPOSE_FILE" rm -f redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5 redis-node-6 || true + docker volume rm redis-node-1-data redis-node-2-data redis-node-3-data redis-node-4-data redis-node-5-data redis-node-6-data 2>/dev/null || true + else + docker compose -f "$COMPOSE_FILE" down -v || true + fi + echo " 정리 완료" + echo "" +fi + +echo "[1] Redis 노드 기동..." +# docker-compose.yml의 경우 Redis 노드만 시작 (다른 서비스 제외) +if echo "$COMPOSE_FILE" | grep -q "docker-compose.yml$"; then + echo " 통합 구성 파일 감지: Redis 노드만 시작합니다..." + docker compose -f "$COMPOSE_FILE" up -d redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5 redis-node-6 +else + docker compose -f "$COMPOSE_FILE" up -d +fi + +echo "[2] 노드 준비 대기..." +# 노드 1이 PONG 응답할 때까지 대기 +for i in {1..40}; do + if docker exec -i redis-node-1 redis-cli -a "$REDIS_PASSWORD" -p 7000 ping >/dev/null 2>&1; then + echo " redis-node-1 OK" + break + fi + sleep 1 + if [ "$i" -eq 40 ]; then + echo "❌ redis-node-1이 준비되지 않았습니다. 로그 확인: docker logs redis-node-1" + exit 1 + fi +done +echo "" + +echo "[3] 클러스터 상태 확인 (이미 구성되어 있으면 스킵)..." +CLUSTER_INFO="$(docker exec -i redis-node-1 redis-cli -a "$REDIS_PASSWORD" -p 7000 cluster info 2>/dev/null || true)" + +if echo "$CLUSTER_INFO" | grep -q "cluster_state:ok"; then + echo " ✅ 이미 cluster_state:ok 입니다. (create 스킵)" +else + echo " 클러스터 create 수행..." + # 컨테이너 네트워크 DNS(hostname) 기반이 가장 안정적 + docker exec -i redis-node-1 redis-cli --cluster create \ + redis-node-1:7000 redis-node-2:7001 redis-node-3:7002 \ + redis-node-4:7003 redis-node-5:7004 redis-node-6:7005 \ + --cluster-replicas 1 \ + --cluster-yes \ + -a "$REDIS_PASSWORD" + + echo " create 완료 후 상태 확인..." + docker exec -i redis-node-1 redis-cli -a "$REDIS_PASSWORD" -p 7000 cluster info \ + | grep -E "cluster_state|cluster_slots_assigned|cluster_known_nodes" || true +fi +echo "" + +# announce 설정은 기본적으로 하지 않음 +# 통합 compose 사용 시: compose 파일에 이미 announce 설정이 있으므로 스킵 +# 분리된 compose 사용 시: 필요시에만 APPLY_ANNOUNCE=1로 활성화 +if [ "$APPLY_ANNOUNCE" = "1" ]; then + if [ -z "$CLUSTER_ANNOUNCE_IP" ]; then + echo "❌ APPLY_ANNOUNCE=1 인데 CLUSTER_ANNOUNCE_IP가 비어있습니다." + echo " 예) CLUSTER_ANNOUNCE_IP=10.0.0.12 APPLY_ANNOUNCE=1 bash docker/init-redis-cluster.sh" + exit 1 + fi + + echo "[4] cluster-announce 설정 적용 (프로덕션 용도)..." + for i in {1..6}; do + PORT=$((7000 + i - 1)) + BUS_PORT=$((17000 + i - 1)) + echo " redis-node-$i (port=$PORT bus=$BUS_PORT) 설정 중..." + docker exec -i "redis-node-$i" redis-cli -a "$REDIS_PASSWORD" -p "$PORT" CONFIG SET cluster-announce-ip "$CLUSTER_ANNOUNCE_IP" >/dev/null || true + docker exec -i "redis-node-$i" redis-cli -a "$REDIS_PASSWORD" -p "$PORT" CONFIG SET cluster-announce-port "$PORT" >/dev/null || true + docker exec -i "redis-node-$i" redis-cli -a "$REDIS_PASSWORD" -p "$PORT" CONFIG SET cluster-announce-bus-port "$BUS_PORT" >/dev/null || true + done + + echo "[5] 노드 재시작 (announce 반영)..." + for i in {1..6}; do + docker restart "redis-node-$i" >/dev/null 2>&1 || true + done + sleep 5 + echo " 재시작 완료" + echo "" +else + if echo "$COMPOSE_FILE" | grep -q "docker-compose.yml$"; then + echo "[4] announce 설정 스킵 (통합 compose에 이미 설정됨: 서비스명 사용)" + else + echo "[4] announce 설정 스킵 (기본값: 필요시 APPLY_ANNOUNCE=1로 활성화)" + fi + echo "" +fi + +echo "============================================" +echo "✅ Redis Cluster 준비 완료" +echo "============================================" +echo "상태 확인:" +echo " docker exec -it redis-node-1 redis-cli -a \"$REDIS_PASSWORD\" -p 7000 cluster info" +echo "노드 확인:" +echo " docker exec -it redis-node-1 redis-cli -a \"$REDIS_PASSWORD\" -p 7000 cluster nodes" diff --git a/docker/monitoring/alertmanager/alertmanager.yml b/docker/monitoring/alertmanager/alertmanager.yml new file mode 100644 index 000000000..31be3f39a --- /dev/null +++ b/docker/monitoring/alertmanager/alertmanager.yml @@ -0,0 +1,121 @@ +# 각 도메인은 자신의 SLACK_WEBHOOK_XXX 환경변수 사용 + +global: + resolve_timeout: 5m + +# 라우팅 설정 +route: + receiver: 'slack-auth-default' + group_by: [ 'alertname', 'severity', 'domain' ] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + + routes: + # ==================== CRITICAL (즉시 알림) ==================== + # 계정 잠금 폭증 - 공격 가능성 + - match: + alertname: HighAccountLockRate + receiver: 'slack-security-critical' + group_wait: 10s + repeat_interval: 30m + + # 보안 위협 탐지 + - match: + alertname: SecurityThreatDetected + receiver: 'slack-security-critical' + group_wait: 10s + repeat_interval: 30m + + # 로그인 실패 급증 - Brute Force 가능성 + - match: + alertname: LoginFailureSpike + receiver: 'slack-security-critical' + group_wait: 10s + repeat_interval: 30m + + # ==================== WARNING ==================== + # 로그인 성공률 저하 + - match: + alertname: LowLoginSuccessRate + receiver: 'slack-auth-warning' + repeat_interval: 2h + + # 이메일 발송 실패율 증가 + - match: + alertname: HighEmailFailureRate + receiver: 'slack-email-warning' + repeat_interval: 2h + + # 이메일 인증 실패 급증 + - match: + alertname: HighVerificationFailureRate + receiver: 'slack-email-warning' + repeat_interval: 2h + + # Rate Limit 다수 트리거 + - match: + alertname: RateLimitTriggered + receiver: 'slack-auth-warning' + repeat_interval: 1h + + # ==================== INFO ==================== + # 회원가입/탈퇴 통계 + - match: + domain: member + severity: info + receiver: 'slack-member-info' + repeat_interval: 24h + +# ==================== Auth 도메인 수신자 ==================== +receivers: + # 기본 (라우팅 안 된 알림) + - name: 'slack-auth-default' + slack_configs: + - api_url: '${SLACK_WEBHOOK_AUTH}' + channel: '#tt-auth-alerts' + send_resolved: true + + # 보안 Critical - 즉시 대응 필요 + - name: 'slack-security-critical' + slack_configs: + - api_url: '${SLACK_WEBHOOK_AUTH}' + channel: '#tt-auth-alerts' + send_resolved: true + + # Auth Warning - 주의 필요 + - name: 'slack-auth-warning' + slack_configs: + - api_url: '${SLACK_WEBHOOK_AUTH}' + channel: '#tt-auth-alerts' + send_resolved: true + + # Email Warning + - name: 'slack-email-warning' + slack_configs: + - api_url: '${SLACK_WEBHOOK_AUTH}' + channel: '#tt-auth-alerts' + send_resolved: true + + # Member 정보성 알림 (일간 리포트 등) + - name: 'slack-member-info' + slack_configs: + - api_url: '${SLACK_WEBHOOK_AUTH}' + channel: '#tt-auth-alerts' + send_resolved: false + +# 알림 억제 규칙 +inhibit_rules: + # Critical 있으면 같은 alertname의 Warning 억제 + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: [ 'alertname' ] + + # 전체 서비스 다운이면 개별 알림 억제 + - source_match: + alertname: 'ServiceDown' + target_match_re: + alertname: '.+' + equal: [ 'instance' ] diff --git a/docker/monitoring/grafana/dashboards/1-service/tt-service-overview.json b/docker/monitoring/grafana/dashboards/1-service/tt-service-overview.json new file mode 100644 index 000000000..a2cad5223 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/1-service/tt-service-overview.json @@ -0,0 +1,55 @@ +# ----- SLO Overview ----- + +# 전체 가용성 (%) +sum(rate(http_server_requests_seconds_count{status!~"5.."}[5m])) +/ +sum(rate(http_server_requests_seconds_count[5m])) +* 100 + +# 에러율 (%) +sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) +/ +sum(rate(http_server_requests_seconds_count[5m])) +* 100 + +# P95 응답시간 (초) +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# Error Budget (7일 SLO 99.9% 기준) +(1 - ((1 - sum(increase(http_server_requests_seconds_count{status!~"5.."}[7d])) / sum(increase(http_server_requests_seconds_count[7d]))) * 7 * 24 * 60 / 10.08)) * 100 + +# Error Budget (30일 SLO 99.9% 기준) +(1 - ((1 - sum(increase(http_server_requests_seconds_count{status!~"5.."}[30d])) / sum(increase(http_server_requests_seconds_count[30d]))) * 30 * 24 * 60 / 43.2)) * 100 + + +# ----- Traffic & Performance ----- + +# 처리량 (RPS) +sum(rate(http_server_requests_seconds_count[1m])) + +# P50 응답시간 +histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# P90 응답시간 +histogram_quantile(0.90, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + +# P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le)) + + +# ----- Business KPI ----- + +# 로그인 성공률 (%) +sum(rate(auth_login_total{result="success"}[5m])) / sum(rate(auth_login_total[5m])) * 100 + +# 이메일 인증 성공률 (%) +sum(rate(email_verification_total{result="success"}[5m])) / sum(rate(email_verification_total[5m])) * 100 + +# 잠긴 계정 수 +auth_locked_account_count + +# 보안 위협 탐지 (유형별) +sum(rate(security_threat_detected_total[5m])) by (type) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-auth-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-auth-dashboard.json new file mode 100644 index 000000000..d0af71f41 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-auth-dashboard.json @@ -0,0 +1,67 @@ +# ----- 로그인 메트릭 ----- + +# 로그인 성공 추이 +sum(rate(auth_login_total{result="success"}[1m])) + +# 로그인 실패 추이 +sum(rate(auth_login_total{result="failure"}[1m])) + +# 로그인 성공률 (%) +sum(rate(auth_login_total{result="success"}[5m])) / sum(rate(auth_login_total[5m])) * 100 + +# Provider별 로그인 (EMAIL) +sum(rate(auth_login_total{provider="EMAIL"}[1m])) by (result) + +# Provider별 로그인 (KAKAO) +sum(rate(auth_login_total{provider="KAKAO"}[1m])) by (result) + +# 로그인 실패 사유별 분포 +sum(rate(auth_login_failure_reason_total[5m])) by (reason) + + +# ----- 계정 보안 ----- + +# 현재 잠긴 계정 수 +auth_locked_account_count + +# 계정 잠금 발생 추이 +sum(rate(auth_account_locked_total[5m])) + +# 계정 잠금 24시간 누적 +sum(increase(auth_account_locked_total[24h])) + + +# ----- 보안 위협 ----- + +# 보안 위협 탐지 (유형별) +sum(rate(security_threat_detected_total[5m])) by (type) + +# 보안 위협 탐지 (심각도별) +sum(rate(security_threat_detected_total[5m])) by (severity) + +# Rate Limit 트리거 (엔드포인트별) +sum(rate(security_rate_limit_triggered_total[5m])) by (endpoint) + +# 24시간 보안 위협 누적 +sum(increase(security_threat_detected_total[24h])) by (type) + + +# ----- 토큰 관리 ----- + +# 토큰 재발급 추이 +sum(rate(auth_token_reissue_total[5m])) + +# 로그아웃 추이 +sum(rate(auth_logout_total[5m])) + + +# ----- Auth API 성능 ----- + +# Auth API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/auth.*"}[5m])) by (le)) + +# Auth API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/auth.*", status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/auth.*"}[5m])) * 100 + +# Auth API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/auth.*"}[1m])) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-email-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-email-dashboard.json new file mode 100644 index 000000000..4e8375306 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-email-dashboard.json @@ -0,0 +1,40 @@ +# ----- 이메일 발송 ----- + +# 이메일 발송 성공 추이 +sum(rate(email_sent_total{result="success"}[1m])) + +# 이메일 발송 실패 추이 +sum(rate(email_sent_total{result="failure"}[1m])) + +# 이메일 발송 성공률 (%) +sum(rate(email_sent_total{result="success"}[5m])) / sum(rate(email_sent_total[5m])) * 100 + +# 24시간 이메일 발송 수 +sum(increase(email_sent_total[24h])) by (result) + + +# ----- 이메일 인증 ----- + +# 이메일 인증 성공 추이 +sum(rate(email_verification_total{result="success"}[1m])) + +# 이메일 인증 실패 추이 +sum(rate(email_verification_total{result="failure"}[1m])) + +# 이메일 인증 성공률 (%) +sum(rate(email_verification_total{result="success"}[5m])) / sum(rate(email_verification_total[5m])) * 100 + +# 24시간 인증 시도 수 +sum(increase(email_verification_total[24h])) by (result) + + +# ----- Email API 성능 ----- + +# Email API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/email.*"}[5m])) by (le)) + +# Email API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/email.*", status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/email.*"}[5m])) * 100 + +# Email API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/email.*"}[1m])) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-lottery-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-lottery-dashboard.json new file mode 100644 index 000000000..92cb35507 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-lottery-dashboard.json @@ -0,0 +1,75 @@ +# ----- 추첨 현황 (LotteryMetrics 기반) ----- + +# 추첨 응모 추이 +sum(rate(lottery_entry_total[ + 1m +])) by (performance_id) + +# 추첨 응모 수량 추이 +sum(rate(lottery_entry_quantity_total[ + 1m +])) by (performance_id) + +# 추첨 당첨 추이 +sum(rate(lottery_draw_total{result="won" +}[ + 1m +])) by (performance_id) + +# 추첨 낙첨 추이 +sum(rate(lottery_draw_total{result="lost" +}[ + 1m +])) by (performance_id) + +# 추첨 당첨률 +sum(rate(lottery_draw_total{result="won" +}[ + 5m +])) / sum(rate(lottery_draw_total[ + 5m +])) * 100 + +# 당첨자 결제 완료 추이 +sum(rate(lottery_winner_payment_total{status="completed" +}[ + 1m +])) by (performance_id) + +# 당첨자 결제 만료 추이 +sum(rate(lottery_winner_payment_total{status="expired" +}[ + 1m +])) by (performance_id) + +# 당첨자 결제 전환율 +sum(rate(lottery_winner_payment_total{status="completed" +}[ + 1h +])) / sum(rate(lottery_winner_payment_total[ + 1h +])) * 100 + + +# ----- Lottery API 성능 ----- + +# Lottery API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/lottery.*" +}[ + 5m +])) by (le)) + +# Lottery API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/lottery.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/lottery.*" +}[ + 5m +])) * 100 + +# Lottery API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/lottery.*" +}[ + 1m +])) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-payment-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-payment-dashboard.json new file mode 100644 index 000000000..16a49bfea --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-payment-dashboard.json @@ -0,0 +1,97 @@ +# ----- 결제 현황 (PaymentMetrics 기반) ----- + +# 결제 성공 추이 +sum(rate(payment_total{result="success" +}[ + 1m +])) + +# 결제 실패 추이 +sum(rate(payment_total{result="failure" +}[ + 1m +])) + +# 결제 성공률 +sum(rate(payment_total{result="success" +}[ + 5m +])) / sum(rate(payment_total[ + 5m +])) * 100 + +# 도메인 타입별 결제 성공 +sum(rate(payment_total{result="success" +}[ + 5m +])) by (domain_type) + +# 결제 수단별 분포 +sum(rate(payment_total{result="success" +}[ + 5m +])) by (method) + +# 결제 실패 사유별 분포 +sum(rate(payment_total{result="failure" +}[ + 5m +])) by (reason) + +# 환불 추이 +sum(rate(payment_refund_total[ + 1m +])) + +# 환불 금액 누적 +sum(increase(payment_refund_amount_total[ + 24h +])) by (domain_type) + +# 결제 금액 분포 P50 +payment_amount{quantile="0.5" +} + +# 결제 금액 분포 P95 +payment_amount{quantile="0.95" +} + +# 결제 처리 시간 P95 +histogram_quantile(0.95, sum(rate(payment_process_duration_seconds_bucket[ + 5m +])) by (le)) + +# 결제 처리 시간 P99 +histogram_quantile(0.99, sum(rate(payment_process_duration_seconds_bucket[ + 5m +])) by (le)) + + +# ----- Payment API 성능 ----- + +# Payment API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/payments.*" +}[ + 5m +])) by (le)) + +# Payment API P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/payments.*" +}[ + 5m +])) by (le)) + +# Payment API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/payments.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/payments.*" +}[ + 5m +])) * 100 + +# Payment API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/payments.*" +}[ + 1m +])) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-performance-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-performance-dashboard.json new file mode 100644 index 000000000..eeeae8fa4 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-performance-dashboard.json @@ -0,0 +1,55 @@ +# ----- 공연 현황 (PerformanceMetrics 기반) ----- + +# 공연 상세 조회 추이 (공연별) +sum(rate(performance_view_total[ + 1m +])) by (performance_id) + +# 공연 상세 조회 전체 RPS +sum(rate(performance_view_total[ + 1m +])) + +# 회차 조회 추이 (회차별) +sum(rate(schedule_view_total[ + 1m +])) by (schedule_id) + +# 좌석 선택 추이 (등급별) +sum(rate(seat_selection_total[ + 1m +])) by (schedule_id, grade) + +# 좌석 등급별 선택 분포 +sum(rate(seat_selection_total[ + 5m +])) by (grade) + +# 인기 공연 TOP 10 (조회수 기준) +topk(10, sum(increase(performance_view_total[ + 24h +])) by (performance_id)) + + +# ----- Performance API 성능 ----- + +# Performance API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/performances.*" +}[ + 5m +])) by (le)) + +# Performance API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/performances.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/performances.*" +}[ + 5m +])) * 100 + +# Performance API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/performances.*" +}[ + 1m +])) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-queue-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-queue-dashboard.json new file mode 100644 index 000000000..43786ae48 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-queue-dashboard.json @@ -0,0 +1,78 @@ +# ----- 대기열 현황 (QueueMetrics 기반) ----- + +# 현재 대기 중인 사용자 수 (queue별) +queue_waiting_count + +# 현재 진입 가능한 사용자 수 (queue별) +queue_enterable_count + +# 대기열 진입 추이 +sum(rate(queue_enter_total[ + 1m +])) by (queue_id) + +# 대기열 이탈 추이 (사유별) +sum(rate(queue_exit_total[ + 1m +])) by (queue_id, reason) + +# 대기열 승격(WAITING → ENTERABLE) 추이 +sum(rate(queue_enterable_total[ + 1m +])) by (queue_id) + +# 대기열 입장 완료 추이 +sum(rate(queue_complete_total[ + 1m +])) by (queue_id) + +# 대기열 이탈률 (만료 비율) +sum(rate(queue_exit_total{reason="EXPIRED" +}[ + 5m +])) / sum(rate(queue_exit_total[ + 5m +])) * 100 + +# 대기열 완료율 (완료 비율) +sum(rate(queue_exit_total{reason="COMPLETED" +}[ + 5m +])) / sum(rate(queue_exit_total[ + 5m +])) * 100 + +# 전체 대기열 진입 RPS +sum(rate(queue_enter_total[ + 1m +])) + + +# ----- Queue API 성능 ----- + +# Queue API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/queues.*" +}[ + 5m +])) by (le)) + +# Queue API P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/queues.*" +}[ + 5m +])) by (le)) + +# Queue API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/queues.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/queues.*" +}[ + 5m +])) * 100 + +# Queue API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/queues.*" +}[ + 1m +])) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-reservation-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-reservation-dashboard.json new file mode 100644 index 000000000..dec7d8d7d --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-reservation-dashboard.json @@ -0,0 +1,95 @@ +# ----- 예약 현황 (ReservationMetrics 기반) ----- + +# 예약 생성 추이 +sum(rate(reservation_total{action="created" +}[ + 1m +])) + +# 예약 완료 추이 +sum(rate(reservation_total{action="completed" +}[ + 1m +])) + +# 예약 실패 추이 +sum(rate(reservation_total{action="failed" +}[ + 1m +])) + +# 예약 만료 추이 +sum(rate(reservation_total{action="expired" +}[ + 1m +])) + +# 예약 취소 추이 +sum(rate(reservation_total{action="cancelled" +}[ + 1m +])) + +# 예약 전환율 (완료/생성) +sum(rate(reservation_total{action="completed" +}[ + 1h +])) / sum(rate(reservation_total{action="created" +}[ + 1h +])) * 100 + +# 현재 PENDING 상태 예약 수 +reservation_pending_count + +# 예약 생성 처리 시간 P95 +histogram_quantile(0.95, sum(rate(reservation_creation_duration_seconds_bucket[ + 5m +])) by (le)) + +# 예약 생성 처리 시간 P99 +histogram_quantile(0.99, sum(rate(reservation_creation_duration_seconds_bucket[ + 5m +])) by (le)) + + +# ----- Reservation API 성능 ----- + +# Reservation API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/reservations.*" +}[ + 5m +])) by (le)) + +# Reservation API P99 응답시간 +histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/reservations.*" +}[ + 5m +])) by (le)) + +# Reservation API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 5m +])) * 100 + +# Reservation API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 1m +])) + +# Reservation API HTTP 상태별 분포 +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 5m +])) by (status) + +# Reservation API 메서드별 RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/reservations.*" +}[ + 1m +])) by (method) diff --git a/docker/monitoring/grafana/dashboards/2-domain/tt-trade-dashboard.json b/docker/monitoring/grafana/dashboards/2-domain/tt-trade-dashboard.json new file mode 100644 index 000000000..3a8166ad5 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/2-domain/tt-trade-dashboard.json @@ -0,0 +1,77 @@ +# ----- 거래 현황 (TradeMetrics 기반) ----- + +# 거래 생성 추이 (유형별) +sum(rate(trade_total{action="created" +}[ + 1m +])) by (type) + +# 거래 완료 추이 (유형별) +sum(rate(trade_total{action="completed" +}[ + 1m +])) by (type) + +# 거래 취소 추이 (유형별) +sum(rate(trade_total{action="cancelled" +}[ + 1m +])) by (type) + +# 거래 성공률 (유형별) +sum(rate(trade_total{action="completed" +}[ + 1h +])) by (type) / sum(rate(trade_total{action="created" +}[ + 1h +])) by (type) * 100 + +# 거래 요청 추이 (유형별) +sum(rate(trade_request_total[ + 1m +])) by (type) + +# 현재 활성 거래 수 +trade_active_count + +# 양도 금액 분포 P50 +trade_price{quantile="0.5" +} + +# 양도 금액 분포 P95 +trade_price{quantile="0.95" +} + +# 양도 vs 교환 비율 +sum(rate(trade_total{action="created", type="TRANSFER" +}[ + 1h +])) / sum(rate(trade_total{action="created" +}[ + 1h +])) * 100 + + +# ----- Trade API 성능 ----- + +# Trade API P95 응답시간 +histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri=~"/api/trades.*" +}[ + 5m +])) by (le)) + +# Trade API 에러율 +sum(rate(http_server_requests_seconds_count{uri=~"/api/trades.*", status=~"5.." +}[ + 5m +])) / sum(rate(http_server_requests_seconds_count{uri=~"/api/trades.*" +}[ + 5m +])) * 100 + +# Trade API RPS +sum(rate(http_server_requests_seconds_count{uri=~"/api/trades.*" +}[ + 1m +])) diff --git a/docker/monitoring/grafana/dashboards/3-infra/tt-container-dashboard.json b/docker/monitoring/grafana/dashboards/3-infra/tt-container-dashboard.json new file mode 100644 index 000000000..8f9f94bb5 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/3-infra/tt-container-dashboard.json @@ -0,0 +1,67 @@ +# ----- Container CPU ----- + +# CPU 사용률 (%) - Pod별 +sum(rate(container_cpu_usage_seconds_total{container="b2st-backend"}[5m])) by (pod) * 100 + +# CPU 사용량 (cores) +sum(rate(container_cpu_usage_seconds_total{container="b2st-backend"}[5m])) by (pod) + +# CPU 요청량 대비 사용률 +sum(rate(container_cpu_usage_seconds_total{container="b2st-backend"}[5m])) by (pod) / sum(kube_pod_container_resource_requests{resource="cpu", container="b2st-backend"}) by (pod) * 100 + + +# ----- Container 메모리 ----- + +# 메모리 사용량 (bytes) +container_memory_usage_bytes{container="b2st-backend"} + +# 메모리 제한 대비 사용률 (%) +container_memory_usage_bytes{container="b2st-backend"} / container_spec_memory_limit_bytes{container="b2st-backend"} * 100 + +# 메모리 워킹 셋 +container_memory_working_set_bytes{container="b2st-backend"} + + +# ----- Container 네트워크 ----- + +# 네트워크 수신 (bytes/s) +rate(container_network_receive_bytes_total{pod=~"b2st.*"}[1m]) + +# 네트워크 송신 (bytes/s) +rate(container_network_transmit_bytes_total{pod=~"b2st.*"}[1m]) + +# 네트워크 수신 패킷 (/s) +rate(container_network_receive_packets_total{pod=~"b2st.*"}[1m]) + +# 네트워크 송신 패킷 (/s) +rate(container_network_transmit_packets_total{pod=~"b2st.*"}[1m]) + +# 네트워크 에러 (수신) +rate(container_network_receive_errors_total{pod=~"b2st.*"}[5m]) + +# 네트워크 에러 (송신) +rate(container_network_transmit_errors_total{pod=~"b2st.*"}[5m]) + + +# ----- Container 디스크 I/O ----- + +# 디스크 읽기 (bytes/s) +rate(container_fs_reads_bytes_total{container="b2st-backend"}[1m]) + +# 디스크 쓰기 (bytes/s) +rate(container_fs_writes_bytes_total{container="b2st-backend"}[1m]) + + +# ----- Pod 상태 ----- + +# Pod 재시작 횟수 +kube_pod_container_status_restarts_total{container="b2st-backend"} + +# Pod 상태 +kube_pod_status_phase{pod=~"b2st.*"} + +# Ready 상태 Pod 수 +sum(kube_pod_status_ready{pod=~"b2st.*", condition="true"}) + +# Pending Pod 수 +sum(kube_pod_status_phase{pod=~"b2st.*", phase="Pending"}) diff --git a/docker/monitoring/grafana/dashboards/3-infra/tt-database-dashboard.json b/docker/monitoring/grafana/dashboards/3-infra/tt-database-dashboard.json new file mode 100644 index 000000000..a8e959139 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/3-infra/tt-database-dashboard.json @@ -0,0 +1,43 @@ +# ----- HikariCP 커넥션 풀 ----- + +# 활성 커넥션 수 +hikaricp_connections_active + +# 대기 중인 커넥션 요청 수 +hikaricp_connections_pending + +# 유휴 커넥션 수 +hikaricp_connections_idle + +# 전체 커넥션 수 +hikaricp_connections + +# 최대 커넥션 수 +hikaricp_connections_max + +# 최소 커넥션 수 +hikaricp_connections_min + +# 커넥션 풀 사용률 (%) +hikaricp_connections_active / hikaricp_connections_max * 100 + + +# ----- 커넥션 획득 ----- + +# 커넥션 획득 평균 시간 (초) +rate(hikaricp_connections_acquire_seconds_sum[5m]) / rate(hikaricp_connections_acquire_seconds_count[5m]) + +# 커넥션 생성 평균 시간 (초) +rate(hikaricp_connections_creation_seconds_sum[5m]) / rate(hikaricp_connections_creation_seconds_count[5m]) + +# 커넥션 사용 평균 시간 (초) +rate(hikaricp_connections_usage_seconds_sum[5m]) / rate(hikaricp_connections_usage_seconds_count[5m]) + + +# ----- 커넥션 문제 ----- + +# 커넥션 타임아웃 발생 횟수 (5분간) +increase(hikaricp_connections_timeout_total[5m]) + +# 커넥션 타임아웃 발생률 +rate(hikaricp_connections_timeout_total[5m]) diff --git a/docker/monitoring/grafana/dashboards/3-infra/tt-jvm-dashboard.json b/docker/monitoring/grafana/dashboards/3-infra/tt-jvm-dashboard.json new file mode 100644 index 000000000..fd29f291f --- /dev/null +++ b/docker/monitoring/grafana/dashboards/3-infra/tt-jvm-dashboard.json @@ -0,0 +1,73 @@ +# ----- 힙 메모리 ----- + +# 힙 메모리 사용률 (%) +jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100 + +# 힙 메모리 사용량 (bytes) +jvm_memory_used_bytes{area="heap"} + +# 힙 메모리 최대 (bytes) +jvm_memory_max_bytes{area="heap"} + +# 힙 메모리 커밋 (bytes) +jvm_memory_committed_bytes{area="heap"} + + +# ----- Non-Heap 메모리 ----- + +# Non-Heap 메모리 사용량 +jvm_memory_used_bytes{area="nonheap"} + +# Metaspace 사용량 +jvm_memory_used_bytes{id="Metaspace"} + + +# ----- GC ----- + +# GC 일시정지 시간 (초/분) +rate(jvm_gc_pause_seconds_sum[5m]) + +# GC 발생 횟수 (/분) +rate(jvm_gc_pause_seconds_count[5m]) + +# GC 일시정지 시간 누적 (5분) +increase(jvm_gc_pause_seconds_sum[5m]) + +# GC Collector별 횟수 +increase(jvm_gc_pause_seconds_count[5m]) by (gc, cause) + + +# ----- 스레드 ----- + +# 활성 스레드 수 +jvm_threads_live_threads + +# 피크 스레드 수 +jvm_threads_peak_threads + +# 데몬 스레드 수 +jvm_threads_daemon_threads + +# 스레드 상태별 분포 +jvm_threads_states_threads + + +# ----- 클래스 로딩 ----- + +# 로딩된 클래스 수 +jvm_classes_loaded_classes + +# 언로드된 클래스 수 +jvm_classes_unloaded_classes_total + + +# ----- CPU ----- + +# 프로세스 CPU 사용률 +process_cpu_usage * 100 + +# 시스템 CPU 사용률 +system_cpu_usage * 100 + +# CPU 수 +system_cpu_count diff --git a/docker/monitoring/grafana/dashboards/3-infra/tt-redis-dashboard.json b/docker/monitoring/grafana/dashboards/3-infra/tt-redis-dashboard.json new file mode 100644 index 000000000..7388c1bb4 --- /dev/null +++ b/docker/monitoring/grafana/dashboards/3-infra/tt-redis-dashboard.json @@ -0,0 +1,75 @@ +# redis_exporter 필요 + +# ----- Redis 메모리 ----- + +# Redis 메모리 사용량 +redis_memory_used_bytes + +# Redis 메모리 최대 +redis_memory_max_bytes + +# Redis 메모리 사용률 (%) +redis_memory_used_bytes / redis_memory_max_bytes * 100 + +# Redis RSS 메모리 +redis_memory_used_rss_bytes + + +# ----- Redis 연결 ----- + +# 연결된 클라이언트 수 +redis_connected_clients + +# 차단된 클라이언트 수 +redis_blocked_clients + +# 거부된 연결 수 (5분간) +increase(redis_rejected_connections_total[5m]) + + +# ----- Redis 명령 ----- + +# 초당 명령 수 +rate(redis_commands_total[1m]) + +# 명령별 초당 수 +rate(redis_commands_total[1m]) by (cmd) + +# 초당 처리된 명령 수 (redis info) +redis_instantaneous_ops_per_sec + + +# ----- Redis 키 ----- + +# 전체 키 수 +redis_db_keys + +# 만료 대기 중인 키 수 +redis_db_keys_expiring + +# 초당 만료된 키 수 +rate(redis_expired_keys_total[1m]) + +# 초당 퇴치된 키 수 +rate(redis_evicted_keys_total[1m]) + + +# ----- Redis 히트율 ----- + +# 키스페이스 히트율 (%) +rate(redis_keyspace_hits_total[5m]) / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])) * 100 + +# 키스페이스 히트 수 +rate(redis_keyspace_hits_total[1m]) + +# 키스페이스 미스 수 +rate(redis_keyspace_misses_total[1m]) + + +# ----- Redis 복제 ----- + +# 연결된 슬레이브 수 +redis_connected_slaves + +# 복제 지연 (초) +redis_replica_offset diff --git a/docker/monitoring/grafana/provisioning/dashboards/default.yml b/docker/monitoring/grafana/provisioning/dashboards/default.yml new file mode 100644 index 000000000..2a94323e7 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/default.yml @@ -0,0 +1,18 @@ +# Grafana Dashboard Provisioning +# 대시보드 JSON 파일 자동 로드 +# /var/lib/grafana/dashboards 경로의 모든 JSON을 자동 임포트 + +apiVersion: 1 + +providers: + - name: 'TT Dashboards' + orgId: 1 + folder: 'TT' # Grafana 내 폴더명 + folderUid: 'tt-dashboards' + type: file + disableDeletion: false # UI에서 삭제 허용 + updateIntervalSeconds: 30 # 파일 변경 감지 주기 + allowUiUpdates: true # UI에서 수정 허용 + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/docker/monitoring/grafana/provisioning/datasources/prometheus.yml b/docker/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 000000000..6b314d8df --- /dev/null +++ b/docker/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,16 @@ +# Grafana Data Sources Provisioning +# Prometheus를 기본 데이터 소스로 자동 설정 + +apiVersion: 1 + +datasources: + # Prometheus - 메인 메트릭 저장소 + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + jsonData: + timeInterval: "15s" + httpMethod: POST diff --git a/docker/monitoring/prometheus/prometheus.yml b/docker/monitoring/prometheus/prometheus.yml new file mode 100644 index 000000000..bef3bcd42 --- /dev/null +++ b/docker/monitoring/prometheus/prometheus.yml @@ -0,0 +1,60 @@ +# TT Project - Prometheus Configuration +# Spring Boot Actuator + Redis Exporter + PostgreSQL Exporter + +global: + scrape_interval: 15s # 메트릭 수집 주기 + evaluation_interval: 15s # 알림 규칙 평가 주기 + external_labels: + cluster: 'tt-production' + env: 'prod' + +# Alertmanager 연동 +alerting: + alertmanagers: + - static_configs: + - targets: + - 'alertmanager:9093' + +# 알림 규칙 파일 +rule_files: + - '/etc/prometheus/rules/*.yml' + +# 스크래핑 대상 설정 +scrape_configs: + # ==================== Spring Boot Application ==================== + - job_name: 'tt-app' + metrics_path: '/actuator/prometheus' + scrape_interval: 10s + static_configs: + - targets: ['app:8080'] + labels: + application: 'tt-backend' + domain: 'all' + + # ==================== Prometheus Self-Monitoring ==================== + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + labels: + application: 'prometheus' + + # ==================== Redis Cluster ==================== + - job_name: 'redis-exporter' + static_configs: + - targets: ['redis-exporter:9121'] + labels: + application: 'redis-cluster' + + # ==================== PostgreSQL ==================== + - job_name: 'postgres-exporter' + static_configs: + - targets: ['postgres-exporter:9187'] + labels: + application: 'postgresql' + + # ==================== Alertmanager ==================== + - job_name: 'alertmanager' + static_configs: + - targets: ['alertmanager:9093'] + labels: + application: 'alertmanager' diff --git a/docker/monitoring/prometheus/rules/auth-alerts.yml b/docker/monitoring/prometheus/rules/auth-alerts.yml new file mode 100644 index 000000000..c2501a8eb --- /dev/null +++ b/docker/monitoring/prometheus/rules/auth-alerts.yml @@ -0,0 +1,164 @@ +# 담당: 인증/인가 도메인 + +groups: + # ==================== Auth 도메인 알림 ==================== + - name: auth_alerts + interval: 30s + rules: + # 🚨 CRITICAL: 로그인 실패 급증 (Brute Force 의심) + - alert: LoginFailureSpike + expr: | + sum(rate(auth_login_total{result="failure"}[5m])) > 10 + for: 2m + labels: + severity: critical + domain: auth + annotations: + summary: "로그인 실패 급증 탐지" + description: "5분간 로그인 실패가 분당 10회 이상입니다. Brute Force 공격 가능성." + current_value: "{{ $value | printf \"%.2f\" }} failures/min" + threshold: "10 failures/min" + + # 🚨 CRITICAL: 계정 잠금 다수 발생 + - alert: HighAccountLockRate + expr: | + sum(increase(auth_account_locked_total[10m])) > 5 + for: 1m + labels: + severity: critical + domain: auth + annotations: + summary: "계정 잠금 다수 발생" + description: "10분간 {{ $value }}개 계정이 잠겼습니다. 공격 또는 시스템 이상 확인 필요." + current_value: "{{ $value }} locks" + threshold: "5 locks/10min" + + # ⚠️ WARNING: 로그인 성공률 저하 + - alert: LowLoginSuccessRate + expr: | + ( + sum(rate(auth_login_total{result="success"}[15m])) + / sum(rate(auth_login_total[15m])) + ) * 100 < 80 + for: 5m + labels: + severity: warning + domain: auth + annotations: + summary: "로그인 성공률 저하" + description: "15분간 로그인 성공률이 {{ $value | printf \"%.1f\" }}%로 80% 미만입니다." + current_value: "{{ $value | printf \"%.1f\" }}%" + threshold: "80%" + + # ⚠️ WARNING: Rate Limit 과다 트리거 + - alert: RateLimitTriggered + expr: | + sum(increase(security_rate_limit_triggered_total[10m])) by (endpoint) > 20 + for: 2m + labels: + severity: warning + domain: auth + annotations: + summary: "Rate Limit 과다 트리거" + description: "엔드포인트 {{ $labels.endpoint }}에서 10분간 {{ $value }}회 Rate Limit 발동." + current_value: "{{ $value }} triggers" + threshold: "20 triggers/10min" + + # ==================== Email 도메인 알림 ==================== + - name: email_alerts + interval: 30s + rules: + # ⚠️ WARNING: 이메일 발송 실패율 증가 + - alert: HighEmailFailureRate + expr: | + ( + sum(rate(email_sent_total{result="failure"}[10m])) + / sum(rate(email_sent_total[10m])) + ) * 100 > 10 + for: 5m + labels: + severity: warning + domain: email + annotations: + summary: "이메일 발송 실패율 증가" + description: "10분간 이메일 발송 실패율이 {{ $value | printf \"%.1f\" }}%입니다." + threshold: "10%" + + # ⚠️ WARNING: 이메일 인증 실패 급증 + - alert: HighVerificationFailureRate + expr: | + ( + sum(rate(email_verification_total{result="failure"}[10m])) + / sum(rate(email_verification_total[10m])) + ) * 100 > 30 + for: 5m + labels: + severity: warning + domain: email + annotations: + summary: "이메일 인증 실패 급증" + description: "10분간 이메일 인증 실패율이 {{ $value | printf \"%.1f\" }}%입니다." + threshold: "30%" + + # ==================== Member 도메인 알림 ==================== + - name: member_alerts + interval: 30s + rules: + # ⚠️ WARNING: 회원 탈퇴 급증 + - alert: HighWithdrawRate + expr: | + sum(increase(member_withdraw_total[1h])) > 10 + for: 5m + labels: + severity: warning + domain: member + annotations: + summary: "회원 탈퇴 급증" + description: "1시간 동안 {{ $value }}명이 탈퇴했습니다. 서비스 이슈 확인 필요." + current_value: "{{ $value }} withdrawals" + threshold: "10 withdrawals/hour" + + # ==================== 보안 위협 알림 ==================== + - name: security_alerts + interval: 15s + rules: + # 🚨 CRITICAL: 보안 위협 탐지 + - alert: SecurityThreatDetected + expr: | + sum(increase(security_threat_detected_total{severity="HIGH"}[5m])) > 0 + for: 0m + labels: + severity: critical + domain: security + annotations: + summary: "고위험 보안 위협 탐지" + description: "심각도 HIGH의 보안 위협이 탐지되었습니다. 즉시 확인 필요." + + # ==================== 서비스 헬스체크 ==================== + - name: service_health + interval: 30s + rules: + # 🚨 CRITICAL: 서비스 다운 + - alert: ServiceDown + expr: up{job="tt-app"} == 0 + for: 1m + labels: + severity: critical + domain: infra + annotations: + summary: "TT 백엔드 서비스 다운" + description: "Spring Boot 애플리케이션이 응답하지 않습니다." + + # ⚠️ WARNING: API 응답 지연 + - alert: HighApiLatency + expr: | + histogram_quantile(0.95, + sum(rate(http_server_requests_seconds_bucket{uri=~"/api/auth.*"}[5m])) by (le) + ) > 2 + for: 5m + labels: + severity: warning + domain: auth + annotations: + summary: "Auth API 응답 지연" + description: "Auth API의 P95 응답시간이 {{ $value | printf \"%.2f\" }}초로 2초를 초과합니다." diff --git a/frontend/service-intro-light.html b/frontend/service-intro-light.html new file mode 100644 index 000000000..4221d58f2 --- /dev/null +++ b/frontend/service-intro-light.html @@ -0,0 +1,881 @@ + + + + + + + TT - 모두에게 공정한 예매서비스 + + + + + + + +
+
+

Fair Ticketing Service

+

+ 티켓팅 이제 울지 마세요 + 모두에게 공정한 예매서비스 +

+ +

+ 추첨 응모와 구역별 사전 등록, 선착순 예매로
+ 모두에게 공정한 티켓팅 서비스를 제공합니다. +

+ +
+
+ + + + +
+
+ + +
+
+ WHY TT? +

우리가 만드는 차이

+

+ 공정한 기회와 투명한 거래, TT가 티켓팅의 새로운 기준을 만들어갑니다. +

+
+ +
+
+

공정한 티켓팅

+

+ 추첨 예매 / 구역별 사전 등록 / 일반 선착순 예매 3가지 경험을 제공합니다. +

+ 공연 특성과 수요에 맞는 방식으로 기회를 분산하고, 규칙을 투명하게 공개해 납득 가능한 예매를 만듭니다. +

+ ✓ 매크로 영향 최소화 +
+ +
+

교환 / 양도 서비스

+

+ 예매 후 일정 변경, 동행 취소 등으로 티켓을 사용하기 어려워졌을 때 외부 플랫폼을 거치지 않고 서비스 내에서 정가로 교환·양도를 한 번에 진행할 수 있습니다. +

+ 프리미엄 거래를 차단해 가격 왜곡과 불공정 거래를 줄이고, 필요한 사람에게 티켓이 정상적으로 돌아가도록 돕습니다. +

+ ✓ 암표 근절 정책 +
+
+
+ + +
+
+ HOW IT WORKS +

예매 방식 안내

+

+ 상황에 맞는 최적의 예매 방식을 선택하세요. +

+
+ +
+ +
+
1
+
+ Lottery +

추첨 예매

+

+ 오픈 시간에 몰리는 클릭 경쟁 대신, 정해진 응모 기간 동안 신청하고 무작위 추첨으로 당첨자를 선정합니다. + 매크로·순간 트래픽 영향을 줄여 기회를 더 공정하게 분배합니다. +

+
+ + + + + 무작위 추첨 + + + + + + 응모 기간 여유 + + + + + + 봇 방지 + +
+
+
+ + +
+
2
+
+ First-Come +

일반 예매

+

+ 오픈 시간에 선착순으로 좌석을 선택·결제하는 가장 익숙한 방식입니다. + 대기열 / 트래픽 제어로 접속 폭주 상황에서도 안정적인 예매 경험을 제공합니다. +

+
+ + + + + 실시간 대기열 + + + + + + 서버 안정성 + + + + + + 원하는 좌석 선택 + +
+
+
+ + +
+
3
+
+ Pre-Register +

구역별 사전 등록

+

+ 예매 전에 원하는 구역과 조건을 미리 등록해두고, 오픈 시점에 해당 구역 기준으로 예매가 진행됩니다. + 좌석 탐색 시간을 줄이고, 인기 구역 경쟁에서도 혼선을 최소화합니다. +

+
+ + + + + 사전 구역 선택 + + + + + + 빠른 예매 + + + + + + 혼잡도 분산 + +
+
+
+
+
+ + +
+

공정한 티켓팅, 지금 시작하세요

+

+ 더 이상 티켓팅에 울지 않아도 됩니다.
+ TT와 함께 새로운 예매 경험을 만나보세요. +

+ +
+ + + + + + + \ No newline at end of file diff --git a/infra/variables.tf b/infra/variables.tf index 789c1b030..601973ff8 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -19,7 +19,7 @@ variable "team_tag" { variable "instance_type" { description = "EC2 instance type" type = string - default = "t3.small" + default = "t3.medium" } variable "root_volume_size" { diff --git a/src/main/java/com/back/b2st/B2stApplication.java b/src/main/java/com/back/b2st/B2stApplication.java index 343206c27..1979773c6 100644 --- a/src/main/java/com/back/b2st/B2stApplication.java +++ b/src/main/java/com/back/b2st/B2stApplication.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication(exclude = { - io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration.class + io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration.class, + org.redisson.spring.starter.RedissonAutoConfigurationV4.class }) + +@ConfigurationPropertiesScan(basePackages = "com.back.b2st") public class B2stApplication { public static void main(String[] args) { diff --git a/src/main/java/com/back/b2st/domain/auth/client/KakaoApiClientImpl.java b/src/main/java/com/back/b2st/domain/auth/client/KakaoApiClientImpl.java index ace03c0f6..f657e4b74 100644 --- a/src/main/java/com/back/b2st/domain/auth/client/KakaoApiClientImpl.java +++ b/src/main/java/com/back/b2st/domain/auth/client/KakaoApiClientImpl.java @@ -23,7 +23,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import tools.jackson.databind.ObjectMapper; @Slf4j @Component @@ -32,7 +31,6 @@ public class KakaoApiClientImpl implements KakaoApiClient { // http 통신용 RestTemplate private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; private final KakaoJwksClient jwksClient; @Value("${oauth.kakao.client-id}") @@ -158,16 +156,16 @@ private KakaoIdTokenPayload parseIdToken(String idToken) { // dto 생성 return new KakaoIdTokenPayload( - claims.getIssuer(), - claims.getAudience().get(0), - claims.getSubject(), // 카카오 회원번호 - claims.getIssueTime() != null ? claims.getIssueTime().getTime() / 1000 : null, - claims.getExpirationTime() != null ? claims.getExpirationTime().getTime() / 1000 : null, - claims.getDateClaim("auth_time") != null ? claims.getDateClaim("auth_time").getTime() / 1000 : null, - claims.getStringClaim("nonce"), - claims.getStringClaim("nickname"), - claims.getStringClaim("picture"), - claims.getStringClaim("email")); + claims.getIssuer(), + claims.getAudience().get(0), + claims.getSubject(), // 카카오 회원번호 + claims.getIssueTime() != null ? claims.getIssueTime().getTime() / 1000 : null, + claims.getExpirationTime() != null ? claims.getExpirationTime().getTime() / 1000 : null, + claims.getDateClaim("auth_time") != null ? claims.getDateClaim("auth_time").getTime() / 1000 : null, + claims.getStringClaim("nonce"), + claims.getStringClaim("nickname"), + claims.getStringClaim("picture"), + claims.getStringClaim("email")); } catch (BusinessException e) { // 이미 적절한 BusinessException은 그대로 전파 diff --git a/src/main/java/com/back/b2st/domain/auth/controller/AuthAdminController.java b/src/main/java/com/back/b2st/domain/auth/controller/AuthAdminController.java new file mode 100644 index 000000000..903b588ce --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/controller/AuthAdminController.java @@ -0,0 +1,89 @@ +package com.back.b2st.domain.auth.controller; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.auth.dto.response.LockedAccountRes; +import com.back.b2st.domain.auth.dto.response.LoginLogAdminRes; +import com.back.b2st.domain.auth.dto.response.SignupLogAdminRes; +import com.back.b2st.domain.auth.service.AuthAdminService; +import com.back.b2st.global.annotation.CurrentUser; +import com.back.b2st.global.common.BaseResponse; +import com.back.b2st.security.UserPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/auth") +@Tag(name = "AuthAdminController", description = "인증/보안 관리 API (관리자 전용)") +@SecurityRequirement(name = "BearerAuth") +public class AuthAdminController { + + private final AuthAdminService authAdminService; + + /** + * 로그인 로그 조회 - 필터링 + 시간 범위 + 페이징 + */ + @GetMapping("/logs/login") + @Operation(summary = "로그인 로그 조회", description = "최근 n시간 내 로그인 시도 기록") + public BaseResponse> getLoginLogs( + @Parameter(description = "이메일 검색") @RequestParam(required = false) String email, + @Parameter(description = "클라이언트 IP") @RequestParam(required = false) String clientIp, + @Parameter(description = "성공 여부") @RequestParam(required = false) Boolean success, + @Parameter(description = "조회 시간 범위(시간)") @RequestParam(defaultValue = "24") int hours, + @Parameter(hidden = true) @PageableDefault(size = 50, sort = "attemptedAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = authAdminService.getLoginLogs(email, clientIp, success, hours, pageable); + return BaseResponse.success(response); + } + + /** + * 회원가입 로그 조회 - 시간 범위 + 페이징 + */ + @GetMapping("/logs/signup") + @Operation(summary = "회원가입 로그 조회", description = "최근 n시간 내 가입 기록") + public BaseResponse> getSignupLogs( + @Parameter(description = "조회 시간 범위(시간)") @RequestParam(defaultValue = "24") int hours, + @Parameter(hidden = true) @PageableDefault(size = 50, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = authAdminService.getSignupLogs(hours, pageable); + return BaseResponse.success(response); + } + + /** + * 잠긴 계정 목록 조회 + */ + @GetMapping("/security/locked-accounts") + @Operation(summary = "잠긴 계정 목록", description = "현재 로그인 잠금 상태인 계정 목록") + public BaseResponse> getLockedAccounts() { + return BaseResponse.success(authAdminService.getLockedAccounts()); + } + + /** + * 계정 잠금 해제 + */ + @DeleteMapping("/security/locked-accounts/{email}") + @Operation(summary = "계정 잠금 해제", description = "특정 회원의 로그인 잠금 해제") + public BaseResponse unlockAccount( + @CurrentUser UserPrincipal admin, + @Parameter(description = "잠금 해제할 이메일") @PathVariable String email + ) { + authAdminService.unlockAccount(admin.getId(), email); + return BaseResponse.success(null); + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/dto/response/LockedAccountRes.java b/src/main/java/com/back/b2st/domain/auth/dto/response/LockedAccountRes.java new file mode 100644 index 000000000..ed2a6d09f --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/dto/response/LockedAccountRes.java @@ -0,0 +1,14 @@ +package com.back.b2st.domain.auth.dto.response; + +public record LockedAccountRes( + String email, + long remainingSeconds, + int remainingMinutes +) { + public static LockedAccountRes of(String email, long remainingSeconds) { + return new LockedAccountRes( + email, + remainingSeconds, + (int)Math.ceil(remainingSeconds / 60.0)); + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/dto/response/LoginLogAdminRes.java b/src/main/java/com/back/b2st/domain/auth/dto/response/LoginLogAdminRes.java new file mode 100644 index 000000000..761724a1c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/dto/response/LoginLogAdminRes.java @@ -0,0 +1,25 @@ +package com.back.b2st.domain.auth.dto.response; + +import java.time.LocalDateTime; + +import com.back.b2st.domain.auth.entity.LoginLog; + +public record LoginLogAdminRes( + Long id, + String email, + String clientIp, + boolean success, + LoginLog.FailReason failReason, + LocalDateTime attemptedAt +) { + public static LoginLogAdminRes from(LoginLog log) { + return new LoginLogAdminRes( + log.getId(), + log.getEmail(), + log.getClientIp(), + log.isSuccess(), + log.getFailReason(), + log.getAttemptedAt() + ); + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/dto/response/SecurityThreatRes.java b/src/main/java/com/back/b2st/domain/auth/dto/response/SecurityThreatRes.java new file mode 100644 index 000000000..972ed05f8 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/dto/response/SecurityThreatRes.java @@ -0,0 +1,83 @@ +package com.back.b2st.domain.auth.dto.response; + +import java.time.LocalDateTime; +import java.util.NavigableMap; +import java.util.TreeMap; + +/** + * 보안 위협 DTO + */ +public record SecurityThreatRes( + String clientIp, + ThreatType threatType, // 위협 유형 + int count, + SeverityLevel severity, // 심각도 수준 + LocalDateTime detectedAt // 탐지 시각 +) { + public enum ThreatType { + CREDENTIAL_STUFFING, // 다수 계정 시도 + BRUTE_FORCE // 단일 계정 무차별 대입 + } + + public enum SeverityLevel { + LOW, // 관찰 필요 + MEDIUM, // 주의 + HIGH, // 경고 + CRITICAL // 즉시 조치 필요 + } + + // Credential Stuffing 임계값 (10+계정:MEDIUM, 20+:HIGH, 50+:CRITICAL) + private static final NavigableMap STUFFING_THRESHOLDS = new TreeMap<>(); + // Brute Force 임계값 (50+실패:MEDIUM, 100+:HIGH, 200+:CRITICAL) + private static final NavigableMap BRUTE_FORCE_THRESHOLDS = new TreeMap<>(); + + /** + * 임계값 초기화 블록 + * - 클래스가 로딩될 때 한 번 실행되어 임계값 맵 설정 + */ + static { + STUFFING_THRESHOLDS.put(10, SeverityLevel.MEDIUM); + STUFFING_THRESHOLDS.put(20, SeverityLevel.HIGH); + STUFFING_THRESHOLDS.put(50, SeverityLevel.CRITICAL); + + BRUTE_FORCE_THRESHOLDS.put(50, SeverityLevel.MEDIUM); + BRUTE_FORCE_THRESHOLDS.put(100, SeverityLevel.HIGH); + BRUTE_FORCE_THRESHOLDS.put(200, SeverityLevel.CRITICAL); + } + + /** + * 크리덴셜 스터핑 탐지 응답 생성 + */ + public static SecurityThreatRes credentialStuffing(String ip, int distinctEmails) { + return new SecurityThreatRes( + ip, + ThreatType.CREDENTIAL_STUFFING, + distinctEmails, + resolveSeverity(distinctEmails, STUFFING_THRESHOLDS), + LocalDateTime.now() + ); + } + + /** + * 브루트 포스 탐지 응답 생성 + */ + public static SecurityThreatRes bruteForce(String ip, int failureCount) { + return new SecurityThreatRes( + ip, + ThreatType.BRUTE_FORCE, + failureCount, + resolveSeverity(failureCount, BRUTE_FORCE_THRESHOLDS), + LocalDateTime.now() + ); + } + + /** + * 임계값 맵을 기반으로 심각도 수준 결정 + */ + private static SeverityLevel resolveSeverity(int count, NavigableMap thresholds) { + // count 이하의 가장 큰 키에 해당하는 값 조회 + var entry = thresholds.floorEntry(count); + // 해당 엔트리 없으면 LOW 반환 + return entry != null ? entry.getValue() : SeverityLevel.LOW; + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/dto/response/SignupLogAdminRes.java b/src/main/java/com/back/b2st/domain/auth/dto/response/SignupLogAdminRes.java new file mode 100644 index 000000000..85b235fc7 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/dto/response/SignupLogAdminRes.java @@ -0,0 +1,21 @@ +package com.back.b2st.domain.auth.dto.response; + +import java.time.LocalDateTime; + +import com.back.b2st.domain.member.entity.SignupLog; + +public record SignupLogAdminRes( + Long id, + String email, + String clientIp, + LocalDateTime createdAt +) { + public static SignupLogAdminRes from(SignupLog log) { + return new SignupLogAdminRes( + log.getId(), + log.getEmail(), + log.getClientIp(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/entity/LoginLog.java b/src/main/java/com/back/b2st/domain/auth/entity/LoginLog.java index f6b9c5098..a5549f9f1 100644 --- a/src/main/java/com/back/b2st/domain/auth/entity/LoginLog.java +++ b/src/main/java/com/back/b2st/domain/auth/entity/LoginLog.java @@ -24,7 +24,7 @@ @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "login_logs", indexes = { @Index(name = "idx_login_log_email", columnList = "email"), @Index(name = "idx_login_log_client_ip", columnList = "clientIp"), diff --git a/src/main/java/com/back/b2st/domain/auth/error/AuthErrorCode.java b/src/main/java/com/back/b2st/domain/auth/error/AuthErrorCode.java index 258e910ab..f2f55e3b1 100644 --- a/src/main/java/com/back/b2st/domain/auth/error/AuthErrorCode.java +++ b/src/main/java/com/back/b2st/domain/auth/error/AuthErrorCode.java @@ -18,9 +18,9 @@ public enum AuthErrorCode implements ErrorCode { // 탈퇴 철회 RECOVERY_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "A406", "복구 토큰이 유효하지 않거나 만료되었습니다."), - // 회원 상태 노출 방지를 위해 모호한 메시지 사용 + // 회원 상태 노출 방지 차원서 모호한 메시지 사용 NOT_WITHDRAWN_MEMBER(HttpStatus.BAD_REQUEST, "A407", "요청을 처리할 수 없습니다."), - WITHDRAWAL_PERIOD_EXPIRED(HttpStatus.BAD_REQUEST, "A408", "복구 가능 기간(30일)이 만료되었습니다."), + WITHDRAWAL_PERIOD_EXPIRED(HttpStatus.BAD_REQUEST, "A408", "요청을 처리할 수 없습니다."), // 소셜 로그인 OAUTH_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "A409", "소셜 로그인 인증에 실패했습니다."), @@ -34,7 +34,8 @@ public enum AuthErrorCode implements ErrorCode { // 일반적으로 발생하지 않음 (이메일 기준 조회라서) OAUTH_ALREADY_LINKED(HttpStatus.CONFLICT, "A412", "이미 다른 계정에 연동된 소셜 계정입니다."), - ACCOUNT_LOCKED(HttpStatus.FORBIDDEN, "A413", "로그인 시도 횟수를 초과하여 계정이 일시적으로 잠겼습니다."); + ACCOUNT_LOCKED(HttpStatus.FORBIDDEN, "A413", "이메일 또는 비밀번호가 정확하지 않습니다."), + ACCOUNT_NOT_LOCKED(HttpStatus.NOT_FOUND, "A414", "해당 계정은 잠금 상태가 아닙니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/back/b2st/domain/auth/listener/LoginEventListener.java b/src/main/java/com/back/b2st/domain/auth/listener/LoginEventListener.java index 3de428f5f..ad0a6af81 100644 --- a/src/main/java/com/back/b2st/domain/auth/listener/LoginEventListener.java +++ b/src/main/java/com/back/b2st/domain/auth/listener/LoginEventListener.java @@ -12,7 +12,10 @@ import com.back.b2st.domain.auth.dto.response.LoginEvent; import com.back.b2st.domain.auth.entity.LoginLog; +import com.back.b2st.domain.auth.metrics.SecurityMetrics; import com.back.b2st.domain.auth.repository.LoginLogRepository; +import com.back.b2st.domain.auth.service.SecurityThreatDetectionService; +import com.back.b2st.global.alert.AlertService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,6 +31,9 @@ public class LoginEventListener { private final LoginLogRepository loginLogRepository; + private final SecurityThreatDetectionService threatDetectionService; + private final AlertService alertService; + private final SecurityMetrics securityMetrics; /** * 로그인 이벤트 처리 @@ -35,7 +41,7 @@ public class LoginEventListener { * @Transactional(REQUIRES_NEW): 기존 트랜잭션과 별도로 새 트랜잭션 생성 * - 로그 저장 실패가 메인 로그인 흐름에 영향 주지 않도록 */ - @Async + @Async("loginEventExecutor") @EventListener @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleLoginEvent(LoginEvent event) { @@ -49,6 +55,16 @@ public void handleLoginEvent(LoginEvent event) { .build(); loginLogRepository.save(loginLog); + + // 실패한 로그인에 대해 보안 위협 탐지 + if (!event.isSuccess()) { + threatDetectionService.detectThreatForIp(event.clientIp()) + .ifPresent(threat -> { + alertService.sendSecurityAlert(threat); + securityMetrics.recordSecurityThreat(threat); + }); + } + log.info("로그인 로그 저장 완료: email={}, success={}, ip={}", maskEmail(event.email()), event.isSuccess(), event.clientIp()); } catch (Exception e) { diff --git a/src/main/java/com/back/b2st/domain/auth/metrics/AuthMetrics.java b/src/main/java/com/back/b2st/domain/auth/metrics/AuthMetrics.java new file mode 100644 index 000000000..b7fc316f1 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/metrics/AuthMetrics.java @@ -0,0 +1,106 @@ +package com.back.b2st.domain.auth.metrics; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class AuthMetrics { + + private final MeterRegistry registry; + private final AtomicInteger lockedAccountCount = new AtomicInteger(0); + + // Counters + private final Counter loginSuccessCounter; + private final Counter loginFailureEmailCounter; + private final Counter loginFailureKakaoCounter; + private final Counter accountLockCounter; + private final Counter tokenReissueCounter; + private final Counter logoutCounter; + + public AuthMetrics(MeterRegistry registry) { + this.registry = registry; + + // 로그인 성공 카운터 + this.loginSuccessCounter = Counter.builder("auth_login_total") + .tag("result", "success") + .tag("provider", "EMAIL") + .description("로그인 성공 횟수") + .register(registry); + + // 이메일 로그인 실패 카운터 + this.loginFailureEmailCounter = Counter.builder("auth_login_total") + .tag("result", "failure") + .tag("provider", "EMAIL") + .description("이메일 로그인 실패 횟수") + .register(registry); + + // 카카오 로그인 실패 카운터 + this.loginFailureKakaoCounter = Counter.builder("auth_login_total") + .tag("result", "failure") + .tag("provider", "KAKAO") + .description("카카오 로그인 실패 횟수") + .register(registry); + + // 계정 잠금 카운터 + this.accountLockCounter = Counter.builder("auth_account_locked_total") + .description("계정 잠금 발생 횟수") + .register(registry); + + // 토큰 재발급 카운터 + this.tokenReissueCounter = Counter.builder("auth_token_reissue_total") + .description("토큰 재발급 횟수") + .register(registry); + + // 로그아웃 카운터 + this.logoutCounter = Counter.builder("auth_logout_total").description("로그아웃 횟수").register(registry); + + // 현재 잠긴 계정 수 (Gauge) + Gauge.builder("auth_locked_account_count", lockedAccountCount, AtomicInteger::get) + .description("현재 잠긴 계정 수") + .register(registry); + } + + public void recordLoginSuccess(String provider) { + loginSuccessCounter.increment(); + } + + public void recordLoginFailure(String provider, String reason) { + if ("KAKAO".equals(provider)) { + loginFailureKakaoCounter.increment(); + } else { + loginFailureEmailCounter.increment(); + } + + // 실패 사유별 카운터 (동적 생성) + Counter.builder("auth_login_failure_reason_total") + .tag("reason", reason) + .register(registry) + .increment(); + } + + public void recordAccountLock() { + accountLockCounter.increment(); + lockedAccountCount.incrementAndGet(); + } + + public void recordAccountUnlock() { + lockedAccountCount.decrementAndGet(); + } + + public void recordTokenReissue() { + tokenReissueCounter.increment(); + } + + public void recordLogout() { + logoutCounter.increment(); + } + + public void setLockedAccountCount(int count) { + lockedAccountCount.set(count); + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/metrics/SecurityMetrics.java b/src/main/java/com/back/b2st/domain/auth/metrics/SecurityMetrics.java new file mode 100644 index 000000000..237f6f696 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/metrics/SecurityMetrics.java @@ -0,0 +1,35 @@ +package com.back.b2st.domain.auth.metrics; + +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.auth.dto.response.SecurityThreatRes; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class SecurityMetrics { + + private final MeterRegistry registry; + + public SecurityMetrics(MeterRegistry registry) { + this.registry = registry; + } + + public void recordSecurityThreat(SecurityThreatRes threat) { + Counter.builder("security_threat_detected_total") + .tag("type", threat.threatType().name()) + .tag("severity", threat.severity().name()) + .description("보안 위협 탐지 횟수") + .register(registry) + .increment(); + } + + public void recordRateLimitTriggered(String endpoint) { + Counter.builder("security_rate_limit_triggered_total") + .tag("endpoint", endpoint) + .description("Rate Limit 트리거 횟수") + .register(registry) + .increment(); + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/repository/LoginLogRepository.java b/src/main/java/com/back/b2st/domain/auth/repository/LoginLogRepository.java index abdb06f1a..b71cb428a 100644 --- a/src/main/java/com/back/b2st/domain/auth/repository/LoginLogRepository.java +++ b/src/main/java/com/back/b2st/domain/auth/repository/LoginLogRepository.java @@ -3,6 +3,8 @@ import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -31,4 +33,41 @@ public interface LoginLogRepository extends JpaRepository { @Query("SELECT COUNT(DISTINCT l.email) FROM LoginLog l " + "WHERE l.clientIp = :ip AND l.attemptedAt > :since") long countDistinctEmailsByIpSince(@Param("ip") String clientIp, @Param("since") LocalDateTime since); + + /** + * 로그인 로그 검색 - 필터링 + 시간 범위 + 페이징 + */ + @Query(""" + SELECT l FROM LoginLog l + WHERE (:email IS NULL OR l.email LIKE %:email%) + AND (:clientIp IS NULL OR l.clientIp = :clientIp) + AND (:success IS NULL OR l.success = :success) + AND l.attemptedAt >= :since + ORDER BY l.attemptedAt DESC + """) + Page searchLogs( + @Param("email") String email, + @Param("clientIp") String clientIp, + @Param("success") Boolean success, + @Param("since") LocalDateTime since, + Pageable pageable + ); + + /** + * 특정 시간 이후의 로그인 시도 횟수 조회 + */ + @Query("SELECT COUNT(l) FROM LoginLog l WHERE l.attemptedAt >= :since") + long countByAttemptedAtAfter(@Param("since") LocalDateTime since); + + /** + * 특정 시간 이후의 로그인 실패 횟수 조회 + */ + @Query("SELECT COUNT(l) FROM LoginLog l WHERE l.success = false AND l.attemptedAt >= :since") + long countFailuresByAttemptedAtAfter(@Param("since") LocalDateTime since); + + /** + * 특정 시간 이후 활성 IP 목록 조회 + */ + @Query("SELECT DISTINCT l.clientIp FROM LoginLog l WHERE l.attemptedAt >= :since") + List findDistinctClientIpsSince(@Param("since") LocalDateTime since); } diff --git a/src/main/java/com/back/b2st/domain/auth/service/AuthAdminService.java b/src/main/java/com/back/b2st/domain/auth/service/AuthAdminService.java new file mode 100644 index 000000000..a8db95ff9 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/service/AuthAdminService.java @@ -0,0 +1,137 @@ +package com.back.b2st.domain.auth.service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.auth.dto.response.LockedAccountRes; +import com.back.b2st.domain.auth.dto.response.LoginLogAdminRes; +import com.back.b2st.domain.auth.dto.response.SignupLogAdminRes; +import com.back.b2st.domain.auth.error.AuthErrorCode; +import com.back.b2st.domain.auth.repository.LoginLogRepository; +import com.back.b2st.domain.member.repository.SignupLogRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class AuthAdminService { + + private static final String LOCK_KEY_PREFIX = "login:lock:"; + private static final String ATTEMPT_KEY_PREFIX = "login:attempt:"; + + private final LoginLogRepository loginLogRepository; + private final SignupLogRepository signupLogRepository; + private final StringRedisTemplate redisTemplate; + + /** + * 관리자용 로그인 로그 조회 + * - 이메일, 클라이언트 IP, 성공 여부, 최근 N시간 필터링 가능 + * - 페이지네이션 + * @param email + * @param clientIp + * @param success 성공 여부 + * @param hours + * @param pageable + * @return 로그인 로그 페이지 + */ + public Page getLoginLogs(String email, String clientIp, Boolean success, int hours, + Pageable pageable) { + // hours 시간 전부터 현재까지의 로그인 로그 조회 + LocalDateTime since = LocalDateTime.now().minusHours(hours); + return loginLogRepository.searchLogs(email, clientIp, success, since, pageable) + .map(LoginLogAdminRes::from); + } + + /** + * 관리자용 회원가입 로그 조회 + * - 최근 N시간 필터링 가능 + * - 페이지네이션 + * @param hours + * @param pageable + * @return 회원가입 로그 페이지 + */ + public Page getSignupLogs(int hours, Pageable pageable) { + // hours 시간 전부터 현재까지의 회원가입 로그 조회 + LocalDateTime since = LocalDateTime.now().minusHours(hours); + return signupLogRepository.findByCreatedAtAfter(since, pageable) + .map(SignupLogAdminRes::from); + } + + /** + * 잠긴 계정 목록 조회 + * - Redis SCAN 명령어 사용 + * @return 잠긴 계정 목록 + */ + public List getLockedAccounts() { + List lockedAccountRes = new ArrayList<>(); + + // Redis SCAN 명령어로 잠긴 계정 키 조회 + ScanOptions scanOptions = ScanOptions.scanOptions() + .match(LOCK_KEY_PREFIX + "*") + .count(100) // 100개씩 + .build(); + + // 커서 사용하여 키 스캔 + try (Cursor cursor = redisTemplate.scan(scanOptions)) { + // 스캔된 각 키에 대해 처리 + while (cursor.hasNext()) { + // 잠긴 계정 키 + String key = cursor.next(); + // 이메일 추출 + String email = key.substring(LOCK_KEY_PREFIX.length()); + // 남은 잠금 시간 조회 + Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS); + + // 잠긴 계정 정보 추가 + if (ttl != null && ttl > 0) { + lockedAccountRes.add(LockedAccountRes.of(email, ttl)); + } + } + } + + return lockedAccountRes; + } + + /** + * 관리자용 계정 잠금 해제 + * @param adminId + * @param email + */ + public void unlockAccount(Long adminId, String email) { + String lockKey = LOCK_KEY_PREFIX + email; + String attemptKey = ATTEMPT_KEY_PREFIX + email; + + // 잠금 상태 확인 + if (Boolean.FALSE.equals(redisTemplate.hasKey(lockKey))) { + throw new BusinessException(AuthErrorCode.ACCOUNT_NOT_LOCKED); + } + + // 잠금 키 삭제 + redisTemplate.delete(lockKey); + // 시도 횟수 키 삭제 + redisTemplate.delete(attemptKey); + + log.info("[Admin] 계정 잠금 해제: adminId={}, email={}", adminId, email); + } + + /** + * 잠긴 계정 수 조회 + */ + public int getLockedAccountCount() { + return getLockedAccounts().size(); + } +} diff --git a/src/main/java/com/back/b2st/domain/auth/service/AuthService.java b/src/main/java/com/back/b2st/domain/auth/service/AuthService.java index 7ccb823f7..6cc00970c 100644 --- a/src/main/java/com/back/b2st/domain/auth/service/AuthService.java +++ b/src/main/java/com/back/b2st/domain/auth/service/AuthService.java @@ -26,6 +26,7 @@ import com.back.b2st.domain.auth.entity.RefreshToken; import com.back.b2st.domain.auth.entity.WithdrawalRecoveryToken; import com.back.b2st.domain.auth.error.AuthErrorCode; +import com.back.b2st.domain.auth.metrics.AuthMetrics; import com.back.b2st.domain.auth.repository.OAuthNonceRepository; import com.back.b2st.domain.auth.repository.RefreshTokenRepository; import com.back.b2st.domain.auth.repository.WithdrawalRecoveryRepository; @@ -60,6 +61,8 @@ public class AuthService { private final OAuthNonceRepository nonceRepository; private final LoginSecurityService loginSecurityService; private final ApplicationEventPublisher eventPublisher; + private final AuthMetrics authMetrics; + @Value("${oauth.kakao.client-id}") private String kakaoClientId; @Value("${oauth.kakao.redirect-uri}") @@ -93,17 +96,20 @@ public TokenInfo login(LoginReq request, String clientIp) { loginSecurityService.onLoginSuccess(email, clientIp); eventPublisher.publishEvent(LoginEvent.success(email, clientIp)); + authMetrics.recordLoginSuccess("EMAIL"); // JWT 발급 return generateTokenForMember(member); } catch (BusinessException e) { // 로그인 실패 처리(시도 횟수 증가) loginSecurityService.recordFailedAttempt(email, clientIp); eventPublisher.publishEvent(LoginEvent.failure(email, clientIp, e.getMessage(), e.getErrorCode())); + authMetrics.recordLoginFailure("EMAIL", e.getErrorCode().getCode()); throw e; } catch (Exception e) { // 기타 예외 처리 loginSecurityService.recordFailedAttempt(email, clientIp); eventPublisher.publishEvent(LoginEvent.failure(email, clientIp, e.getMessage(), null)); + authMetrics.recordLoginFailure("EMAIL", "UNKNOWN_ERROR"); throw e; } } @@ -116,24 +122,28 @@ public TokenInfo login(LoginReq request, String clientIp) { */ @Transactional public TokenInfo kakaoLogin(KakaoLoginReq request) { - // OIDC 호출. 액세스 토큰 발급 + 정보 조회 - KakaoIdTokenPayload payload = fetchKakaoUserInfo(request.code()); - - // nonce 검증 - validateNonce(payload.nonce()); - - // 검증 - validateKakaoEmail(payload); + try { + // OIDC 호출. 액세스 토큰 발급 + 정보 조회 + KakaoIdTokenPayload payload = fetchKakaoUserInfo(request.code()); + // nonce 검증 + validateNonce(payload.nonce()); + // 검증 + validateKakaoEmail(payload); - // 회원 처리 - Member member = findOrCreateKakaoMember(payload); - validateNotWithdrawn(member); + // 회원 처리 + Member member = findOrCreateKakaoMember(payload); + validateNotWithdrawn(member); - // JWT 발급 - TokenInfo tokenInfo = generateTokenForMember(member); - log.info("[Kakao] 로그인 성공: MemberID={}, Email={}", member.getId(), maskEmail(member.getEmail())); + // JWT 발급 + TokenInfo tokenInfo = generateTokenForMember(member); + log.info("[Kakao] 로그인 성공: MemberID={}, Email={}", member.getId(), maskEmail(member.getEmail())); - return tokenInfo; + authMetrics.recordLoginSuccess("KAKAO"); + return tokenInfo; + } catch (BusinessException e) { + authMetrics.recordLoginFailure("KAKAO", e.getErrorCode().getCode()); + throw e; + } } /** @@ -213,6 +223,7 @@ public TokenInfo reissue(String accessToken, String refreshToken) { saveRefreshToken(email, newToken.refreshToken(), storedToken.getFamily(), storedToken.getGeneration() + 1); + authMetrics.recordTokenReissue(); return newToken; } @@ -224,6 +235,7 @@ public TokenInfo reissue(String accessToken, String refreshToken) { @Transactional public void logout(UserPrincipal principal) { refreshTokenRepository.deleteById(principal.getEmail()); + authMetrics.recordLogout(); } /** diff --git a/src/main/java/com/back/b2st/domain/auth/service/LoginSecurityService.java b/src/main/java/com/back/b2st/domain/auth/service/LoginSecurityService.java index 0218c1d48..ccf0e7124 100644 --- a/src/main/java/com/back/b2st/domain/auth/service/LoginSecurityService.java +++ b/src/main/java/com/back/b2st/domain/auth/service/LoginSecurityService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import com.back.b2st.domain.auth.error.AuthErrorCode; +import com.back.b2st.domain.auth.metrics.AuthMetrics; import com.back.b2st.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -27,6 +28,8 @@ @Slf4j public class LoginSecurityService { + private final AuthMetrics authMetrics; + // 세팅 상수 private static final int MAX_ATTEMPTS = 5; // 최대 로그인 시도 횟수 private static final Duration LOCKOUT_DURATION = Duration.ofMinutes(10); // 계정 잠금 시간 @@ -50,7 +53,6 @@ private DefaultRedisScript createIncrementScript() { /** * 로그인 전 계정 잠금 상태 확인 - * 잠겨있으면 BusinessException 발생 * * @param email 확인할 이메일 * @throws BusinessException ACCOUNT_LOCKED @@ -61,9 +63,9 @@ public void checkAccountLock(String email) { if (Boolean.TRUE.equals(redisTemplate.hasKey(lockKey))) { // null 방지 Long ttl = redisTemplate.getExpire(lockKey, TimeUnit.SECONDS); int remainingMinutes = ttl != null ? (int)Math.ceil(ttl / 60.0) : 0; - // 내부 로그에만 잠금 정보 기록 (운영용) + // 내부 로그에만 잠금 정보 기록 log.warn("🔒 잠긴 계정 로그인 시도: email={}, 남은시간={}분", maskEmail(email), remainingMinutes); - // 클라이언트에는 일반 로그인 실패로 응답 (보안: 계정 잠금 상태 노출 방지) + // 클라이언트에는 일반 로그인 실패로 응답 throw new BusinessException(AuthErrorCode.LOGIN_FAILED); } } @@ -94,10 +96,11 @@ public void recordFailedAttempt(String email, String clientIp) { // 최대 시도 초과 시 계정 잠금 if (attempts >= MAX_ATTEMPTS) { lockAccount(email); - // 내부 로그에만 잠금 정보 기록 (운영용) + authMetrics.recordAccountLock(); + // 내부 로그에만 잠금 정보 기록 log.warn("🔒 계정 잠금 발생: email={}, IP={}, 잠금시간={}분", maskEmail(email), clientIp, LOCKOUT_DURATION.toMinutes()); - // 클라이언트에는 일반 로그인 실패로 응답 (보안: 계정 잠금 상태 노출 방지) + // 클라이언트에는 일반 로그인 실패로 응답 throw new BusinessException(AuthErrorCode.LOGIN_FAILED); } } diff --git a/src/main/java/com/back/b2st/domain/auth/service/SecurityThreatDetectionService.java b/src/main/java/com/back/b2st/domain/auth/service/SecurityThreatDetectionService.java new file mode 100644 index 000000000..d7f78e824 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/auth/service/SecurityThreatDetectionService.java @@ -0,0 +1,76 @@ +package com.back.b2st.domain.auth.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.auth.dto.response.SecurityThreatRes; +import com.back.b2st.domain.auth.repository.LoginLogRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 보안 위협 탐지 서비스 + * - 다수 계정 시도 + * - 단일 계정 무차별 대입 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class SecurityThreatDetectionService { + + private final LoginLogRepository loginLogRepository; + + // 임계값 설정 + private static final int CREDITIAL_STUFFING_THRESHOLD = 10; + private static final int BRUTE_FORCE_THRESHOLD = 50; + + /** + * 현재 활성 위협 목록 조회 + */ + public List detectActiveThreats() { + LocalDateTime since = LocalDateTime.now().minusHours(1); + + // 최근 1시간 내 시도된 고유 IP 목록 조회 + return loginLogRepository.findDistinctClientIpsSince(since).stream() + .map(ip -> detectThreatForIp(ip, since)) + .flatMap(Optional::stream) + .peek(threat -> log.warn("[보안 위협] {} 감지: IP={}, 횟수={}", + threat.threatType(), threat.clientIp(), threat.count())) + .toList(); + } + + /** + * 특정 IP에 대한 위협 탐지 (public - 이벤트 트리거용) + */ + public Optional detectThreatForIp(String clientIp) { + return detectThreatForIp(clientIp, LocalDateTime.now().minusHours(1)); + } + + /** + * 특정 IP에 대한 위협 탐지 (private) + */ + private Optional detectThreatForIp(String clientIp, LocalDateTime since) { + + // 다수 계정 시도 (Credential Stuffing) 탐지 + long distinctEmails = loginLogRepository.countDistinctEmailsByIpSince(clientIp, since); + // 임계값 초과 시 위협으로 간주 + if (distinctEmails >= CREDITIAL_STUFFING_THRESHOLD) { + return Optional.of(SecurityThreatRes.credentialStuffing(clientIp, (int)distinctEmails)); + } + + // 단일 계정 무차별 대입 (Brute Force) 탐지 + long failedAttempts = loginLogRepository.countFailedAttemptsByIpSince(clientIp, since); + // 임계값 초과 시 위협으로 간주 + if (failedAttempts >= BRUTE_FORCE_THRESHOLD) { + return Optional.of(SecurityThreatRes.bruteForce(clientIp, (int)failedAttempts)); + } + + return Optional.empty(); + } +} diff --git a/src/main/java/com/back/b2st/domain/email/metrics/EmailMetrics.java b/src/main/java/com/back/b2st/domain/email/metrics/EmailMetrics.java new file mode 100644 index 000000000..28067849b --- /dev/null +++ b/src/main/java/com/back/b2st/domain/email/metrics/EmailMetrics.java @@ -0,0 +1,53 @@ +package com.back.b2st.domain.email.metrics; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class EmailMetrics { + + private final Counter emailSentSuccessCounter; + private final Counter emailSentFailureCounter; + private final Counter verificationSuccessCounter; + private final Counter verificationFailureCounter; + + public EmailMetrics(MeterRegistry registry) { + this.emailSentSuccessCounter = Counter.builder("email_sent_total") + .tag("result", "success") + .description("이메일 발송 성공 횟수") + .register(registry); + + this.emailSentFailureCounter = Counter.builder("email_sent_total") + .tag("result", "failure") + .description("이메일 발송 실패 횟수") + .register(registry); + + this.verificationSuccessCounter = Counter.builder("email_verification_total") + .tag("result", "success") + .description("이메일 인증 성공 횟수") + .register(registry); + + this.verificationFailureCounter = Counter.builder("email_verification_total") + .tag("result", "failure") + .description("이메일 인증 실패 횟수") + .register(registry); + } + + public void recordEmailSent(boolean success) { + if (success) { + emailSentSuccessCounter.increment(); + } else { + emailSentFailureCounter.increment(); + } + } + + public void recordVerification(boolean success) { + if (success) { + verificationSuccessCounter.increment(); + } else { + verificationFailureCounter.increment(); + } + } +} diff --git a/src/main/java/com/back/b2st/domain/email/service/EmailSender.java b/src/main/java/com/back/b2st/domain/email/service/EmailSender.java index 7d06b77cc..230dc6674 100644 --- a/src/main/java/com/back/b2st/domain/email/service/EmailSender.java +++ b/src/main/java/com/back/b2st/domain/email/service/EmailSender.java @@ -2,6 +2,8 @@ import static com.back.b2st.global.util.MaskingUtil.*; +import java.time.LocalDateTime; +import java.util.HashMap; import java.util.Map; import org.springframework.beans.factory.annotation.Value; @@ -12,6 +14,8 @@ import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; +import com.back.b2st.domain.seat.grade.entity.SeatGradeType; + import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; @@ -49,6 +53,53 @@ public void sendRecoveryEmail(String to, String name, String recoveryLink) { )); } + @Async("emailExecutor") + public void sendNotificationEmail(String to, String subject, String message) { + sendTemplateEmail(to, subject, "email/notification", + Map.of( + "message", message + )); + } + + @Async("emailExecutor") + public void sendNotificationEmail(String to, String subject, String message, String actionText, String actionUrl) { + Map variables = new HashMap<>(); + variables.put("message", message); + if (actionUrl != null && !actionUrl.isBlank()) { + variables.put("actionUrl", actionUrl); + variables.put("actionText", (actionText == null || actionText.isBlank()) ? "바로가기" : actionText); + } + sendTemplateEmail(to, subject, "email/notification", variables); + } + + @Async("emailExecutor") + public void sendLotteryWinnerEmail( + String to, + String name, + SeatGradeType grade, + Integer quantity, + LocalDateTime paymentDeadline + ) { + sendTemplateEmail(to, "[TT] 추첨 당첨 안내", "email/lottery-winner", + Map.of( + "name", name, + "grade", grade, + "quantity", quantity, + "paymentDeadline", paymentDeadline + )); + } + + @Async("emailExecutor") + public void sendCancelUnpaidEmail( + String to, + String name + ) { + sendTemplateEmail(to, "[TT] 당첨 취소 안내", "email/lottery-cancel", + Map.of( + "name", name + )); + } + @Async("emailExecutor") public void sendTemplateEmail(String to, String subject, String templateName, Map variables) { Context context = new Context(); diff --git a/src/main/java/com/back/b2st/domain/email/service/EmailService.java b/src/main/java/com/back/b2st/domain/email/service/EmailService.java index 4b5eca774..e1e9f6c09 100644 --- a/src/main/java/com/back/b2st/domain/email/service/EmailService.java +++ b/src/main/java/com/back/b2st/domain/email/service/EmailService.java @@ -3,6 +3,7 @@ import static com.back.b2st.global.util.MaskingUtil.*; import java.security.SecureRandom; +import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,8 +14,12 @@ import com.back.b2st.domain.email.dto.response.CheckDuplicateRes; import com.back.b2st.domain.email.entity.EmailVerification; import com.back.b2st.domain.email.error.EmailErrorCode; +import com.back.b2st.domain.email.metrics.EmailMetrics; import com.back.b2st.domain.email.repository.EmailVerificationRepository; +import com.back.b2st.domain.lottery.result.dto.LotteryResultEmailInfo; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; import com.back.b2st.domain.member.entity.Member; +import com.back.b2st.domain.member.error.MemberErrorCode; import com.back.b2st.domain.member.repository.MemberRepository; import com.back.b2st.global.error.exception.BusinessException; @@ -29,9 +34,11 @@ public class EmailService { // 코드 난수 private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final EmailVerificationRepository emailVerificationRepository; + private final LotteryResultRepository lotteryResultRepository; private final EmailSender emailSender; private final MemberRepository memberRepository; private final EmailRateLimiter rateLimiter; + private final EmailMetrics emailMetrics; /** * 이메일 중복 확인 - existsBy 조회 최적화 + boolean 반전 @@ -71,13 +78,14 @@ public void sendVerificationCode(SenderVerificationReq request) { .build(); emailVerificationRepository.save(emailVerification); - log.info("인증 코드 저장 완료: email={}", maskEmail(email)); // 비동기 발송 try { emailSender.sendEmailAsync(email, code); + emailMetrics.recordEmailSent(true); } catch (Exception e) { + emailMetrics.recordEmailSent(false); log.error("이메일 발송 요청 실패: {}", e.getMessage()); // 저장은 완료되었으니 예외 throw하지 않음 } @@ -100,6 +108,7 @@ public void verifyCode(VerifyCodeReq request) { // 시도 횟수 확인 if (verification.isMaxAttemptExceeded()) { emailVerificationRepository.deleteById(email); + emailMetrics.recordVerification(false); throw new BusinessException(EmailErrorCode.VERIFICATION_MAX_ATTEMPT); } @@ -109,6 +118,7 @@ public void verifyCode(VerifyCodeReq request) { EmailVerification updated = verification.incrementAttempt(); emailVerificationRepository.save(updated); + emailMetrics.recordVerification(false); log.warn("인증 코드 불일치: email={}, attempt={}", maskEmail(email), updated.getAttemptCount()); throw new BusinessException(EmailErrorCode.VERIFICATION_CODE_MISMATCH); } @@ -121,9 +131,107 @@ public void verifyCode(VerifyCodeReq request) { memberRepository.findByEmail(email) .ifPresent(Member::verifyEmail); + emailMetrics.recordVerification(true); log.info("이메일 인증 성공: email={}", maskEmail(email)); } + /** + * 특정 회차의 당첨자에게 이메일 발송 + */ + @Transactional(readOnly = true) + public void sendWinnerNotifications(Long scheduleId) { + log.info("당첨자 이메일 발송 시작 - scheduleId: {}", scheduleId); + + List winners = lotteryResultRepository + .findSendEmailInfoByScheduleId(scheduleId); + + if (winners.isEmpty()) { + log.info("당첨자 없음 - scheduleId: {}", scheduleId); + return; + } + + log.debug("당첨자 수: {}", winners.size()); + + int successCount = 0; + int failCount = 0; + + for (LotteryResultEmailInfo winner : winners) { + try { + sendWinnerEmail(winner); + successCount++; + } catch (Exception e) { + failCount++; + log.error("이메일 발송 실패 - resultId: {}, memberId: {}, error: {}", + winner.id(), winner.memberId(), e.getMessage()); + } + } + + log.info("당첨자 이메일 발송 완료 - 성공: {}, 실패: {}", successCount, failCount); + } + + /** + * 개별 당첨자에게 이메일 발송 + */ + private void sendWinnerEmail(LotteryResultEmailInfo winner) { + Member member = memberRepository.findById(winner.memberId()) + .orElseThrow(() -> new BusinessException(MemberErrorCode.MEMBER_NOT_FOUND)); + + emailSender.sendLotteryWinnerEmail( + member.getEmail(), + winner.memberName(), + winner.seatGrade(), + winner.quantity(), + winner.paymentDeadline() + ); + + log.info("당첨 안내 이메일 발송 완료 - email: {}, resultId: {}", + maskEmail(member.getEmail()), winner.id()); + } + + /** + * 결제 기한 초과로 당첨 취소된 사용자에게 이메일 발송 + */ + @Transactional(readOnly = true) + public void sendCancelUnpaidNotifications(List memberIds) { + log.info("당첨 취소 이메일 발송 시작"); + + if (memberIds.isEmpty()) { + log.info("당첨 취소 대상 없음"); + return; + } + + int successCount = 0; + int failCount = 0; + + for (Long memberId : memberIds) { + try { + sendCancelUnpaidEmail(memberId); + successCount++; + } catch (Exception e) { + failCount++; + log.error("당첨 취소 이메일 발송 실패 - memberId: {}", memberId, e); + } + } + + log.info("당첨 취소 이메일 발송 완료 - 성공: {}, 실패: {}", successCount, failCount); + } + + /** + * 취소 안내 메일 발송 + */ + private void sendCancelUnpaidEmail(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(MemberErrorCode.MEMBER_NOT_FOUND)); + + emailSender.sendCancelUnpaidEmail( + member.getEmail(), + member.getName() + ); + + log.info("당첨 안내 이메일 발송 완료 - email: {}, resultId: {}", + maskEmail(member.getEmail()), member.getId()); + } + // 밑으로 헬퍼 메서드 // 보안 코드 생성 private String generateSecureCode() { diff --git a/src/main/java/com/back/b2st/domain/lottery/draw/controller/DrawController.java b/src/main/java/com/back/b2st/domain/lottery/draw/controller/DrawController.java new file mode 100644 index 000000000..f5b35ab9f --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/draw/controller/DrawController.java @@ -0,0 +1,35 @@ +package com.back.b2st.domain.lottery.draw.controller; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.lottery.draw.service.DrawService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/draw") +@RequiredArgsConstructor +public class DrawController { + + private final DrawService drawService; + + // test + @PostMapping("/executedraws") + public void executeDraws() { + drawService.executeDraws(); + } + + // test + @PostMapping("/executeAllocation") + public void executeAllocation() { + drawService.executeAllocation(); + } + + // test + @PostMapping("/executecancelUnpaid") + public void executecancelUnpaid() { + drawService.executecancelUnpaid(); + } +} diff --git a/src/main/java/com/back/b2st/domain/lottery/draw/scheduler/DrawScheduler.java b/src/main/java/com/back/b2st/domain/lottery/draw/scheduler/DrawScheduler.java index 8814c8c5c..c8cec44c2 100644 --- a/src/main/java/com/back/b2st/domain/lottery/draw/scheduler/DrawScheduler.java +++ b/src/main/java/com/back/b2st/domain/lottery/draw/scheduler/DrawScheduler.java @@ -2,7 +2,6 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import com.back.b2st.domain.lottery.draw.service.DrawService; @@ -16,16 +15,30 @@ public class DrawScheduler { private final DrawService drawService; - @Scheduled(cron = "${lottery.draw.cron:0 0 3 * * *}") - @Transactional + @Scheduled(cron = "0 0 3 * * *") public void executeDailyDraw() { - log.info("=== 추첨 스케줄러 시작 ==="); - try { drawService.executeDraws(); - log.info("=== 추첨 스케줄러 완료 ==="); } catch (Exception e) { - log.error("=== 추첨 스케줄러 실패 ===", e); + log.error("Error, drawService.executeDraws()", e); + } + } + + @Scheduled(cron = "0 0 5 * * *") + public void executeAllocation() { + try { + drawService.executeAllocation(); + } catch (Exception e) { + log.error("Error, drawService.executeAllocation()", e); + } + } + + @Scheduled(cron = "0 0 0 * * *") + public void executecancelUnpaid() { + try { + drawService.executecancelUnpaid(); + } catch (Exception e) { + log.error("Error, drawService.executecancelUnpaid()", e); } } } diff --git a/src/main/java/com/back/b2st/domain/lottery/draw/service/CancelUnpaidService.java b/src/main/java/com/back/b2st/domain/lottery/draw/service/CancelUnpaidService.java new file mode 100644 index 000000000..e3116a27c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/draw/service/CancelUnpaidService.java @@ -0,0 +1,31 @@ +package com.back.b2st.domain.lottery.draw.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +class CancelUnpaidService { + + private final LotteryResultRepository lotteryResultRepository; + + @Transactional + public List cancelUnpaid() { + LocalDateTime now = LocalDateTime.now(); + List memberIds = lotteryResultRepository.findCancelUnpaidAll(now); + + int count = lotteryResultRepository.removeUnpaidAll(now); + log.info("{} 미결제자 취소: {} 건", now, count); + + return memberIds; + } +} diff --git a/src/main/java/com/back/b2st/domain/lottery/draw/service/DrawService.java b/src/main/java/com/back/b2st/domain/lottery/draw/service/DrawService.java index a008aeb91..5f731ac1c 100644 --- a/src/main/java/com/back/b2st/domain/lottery/draw/service/DrawService.java +++ b/src/main/java/com/back/b2st/domain/lottery/draw/service/DrawService.java @@ -1,29 +1,10 @@ package com.back.b2st.domain.lottery.draw.service; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ThreadLocalRandom; -import java.util.stream.Collectors; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import com.back.b2st.domain.lottery.draw.dto.LotteryApplicantInfo; -import com.back.b2st.domain.lottery.draw.dto.WeightedApplicant; -import com.back.b2st.domain.lottery.draw.dto.WinnerInfo; -import com.back.b2st.domain.lottery.entry.repository.LotteryEntryRepository; -import com.back.b2st.domain.lottery.result.entity.LotteryResult; -import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; import com.back.b2st.domain.performanceschedule.dto.DrawTargetPerformance; -import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; -import com.back.b2st.domain.seat.grade.entity.SeatGradeType; -import com.back.b2st.domain.seat.grade.repository.SeatGradeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -33,245 +14,65 @@ @Slf4j public class DrawService { - private static final String p = "=== [DrawService_ "; - - private final PerformanceScheduleRepository performanceScheduleRepository; - private final LotteryEntryRepository lotteryEntryRepository; - private final SeatGradeRepository seatGradeRepository; - private final LotteryResultRepository lotteryResultRepository; + private final PerformanceDrawService performanceDrawService; + private final SeatAllocationService seatAllocationService; + private final CancelUnpaidService cancelUnpaidService; + private final LotteryNotificationService notificationService; public void executeDraws() { - log.info("추첨 시작"); - - List targetPerformances = findBookingClosedPerformances(); + // test + List targetPerformances = performanceDrawService.findBookingClosedPerformances_test(); - log.info("{}executeDraws] 추첨 대상 공연 수 : {}", p, targetPerformances.size()); + // List targetPerformances = performanceDrawService.findBookingClosedPerformances(); + log.debug("추첨 대상 공연 수 : {}", targetPerformances.size()); // 각 공연별 추첨 for (DrawTargetPerformance performance : targetPerformances) { + Long performanceId = performance.performanceId(); + Long scheduleId = performance.performanceScheduleId(); + try { - drawForPerformance(performance.performanceId(), performance.performanceScheduleId()); - log.info("{}executeDraws] 공연 추첨 완료 - scheduleId: {}", p, performance.performanceScheduleId()); + performanceDrawService.drawForPerformance(performanceId, scheduleId); + notificationService.notifyWinners(scheduleId); + log.debug("공연 추첨 완료 - scheduleId: {}", scheduleId); } catch (Exception e) { - log.error("{}executeDraws] 공연 추첨 실패 - scheduleId: {}", - p, performance.performanceScheduleId(), e); - // todo 실패 공연 따로 저장 후 재시도 진행? + log.error("공연 추첨 실패 - scheduleId: {}", scheduleId, e); } } } /** - * 마감 공연 조회 - * @return DrawTargetPerformance = 공연id, 회차id - */ - private List findBookingClosedPerformances() { - LocalDateTime startDate = LocalDate.now().minusDays(1).atStartOfDay(); - LocalDateTime endDate = LocalDate.now().atStartOfDay(); - - return performanceScheduleRepository.findByClosedBetweenAndNotDrawn(startDate, endDate); - } - - /** - * 공연 추첨 진행 - * @param performanceId 공연 id - * @param scheduleId 회차 id + * 좌석 할당 실행 */ - @Transactional - protected void drawForPerformance(Long performanceId, Long scheduleId) { - // 응모자 목록 가져오기 - List entryInfos = lotteryEntryRepository.findAppliedInfoByScheduleId( - scheduleId); - - if (entryInfos.size() == 0) { - log.info("{}drawForPerformance] 데이터 없음", p); - return; - } - log.info("{}drawForPerformance] 응모자 수 : {}", p, entryInfos.size()); - - // 등급별 좌석 수 - EnumMap seatCountByGrade = toSeatCountMap(performanceId); - - // 등급별 응모자 그룹핑 - EnumMap> byGrade = entryInfos.stream() - .collect(Collectors.groupingBy( - LotteryApplicantInfo::grade, - () -> new EnumMap<>(SeatGradeType.class), - Collectors.toList() - )); - - List allWinnerIds = new ArrayList<>(); - - // 각 등급별 추첨 진행 - for (SeatGradeType gradeType : SeatGradeType.values()) { - allWinnerIds.addAll( - drawByGrade(gradeType, byGrade, seatCountByGrade, scheduleId) - ); - } - - // todo 결과 저장 - // lotteryEntry -> status WIN/LOSE 변경 필요 - // 일괄 변경 작업이 필요하면 LotteryEntry 채로 들고오는 게 맞는데, - // LotteryEntity 객체를 가져와서 status 업데이트 하기 - // vs - // id만 우선조회 하고 해당하는 id의 객체만 가져오기 - - // 추첨 완료 회차 & !WIN => LOSE 일괄 변경? -> 필요한 작업인가? - lotteryEntryRepository.updateStatusBySchedule(scheduleId, allWinnerIds); - performanceScheduleRepository.updateStautsById(scheduleId); - - // 응모 id와 당첨자 id추출 - List winnerInfos = entryInfos.stream() - .filter(info -> allWinnerIds.contains(info.id())) - .map(info -> new WinnerInfo(info.id(), info.memberId())) - .toList(); - - saveLotteryResult(winnerInfos); - } - - /** - * 당첨자 저장 - * @param winnerInfos 응모id, 사용자 id - */ - private void saveLotteryResult(List winnerInfos) { - List results = new ArrayList<>(); - - for (WinnerInfo info : winnerInfos) { - results.add( - LotteryResult.builder() - .lotteryEntryId(info.id()) - .memberId(info.memberId()) - .build() - ); - } - - lotteryResultRepository.saveAll(results); - } - - /** - * 등급별 추첨 진행 - * @param grade 등급 - * @param byGrade 등급별 응모자 리스트 - * @param seatCountByGrade 등급별 좌석 수 - * @param scheduleId 공연 회차 id - */ - private List drawByGrade( - SeatGradeType grade, - Map> byGrade, - EnumMap seatCountByGrade, - Long scheduleId - ) { - // 해당 등급 응모자 리스트 - List applicantInfos = byGrade.getOrDefault(grade, List.of()); - // 해당 등급 좌석 수 - Long seatCounts = seatCountByGrade.getOrDefault(grade, 0L); - - log.info("{} drawByGrade()] 등급: {}, 좌석 수: {}, 응모자 수: {}", p, grade, seatCounts, applicantInfos.size()); + public void executeAllocation() { + // tset + List targetPerformances = seatAllocationService.findBookingOpenPerformances_test(); + + // 좌석 할당이 필요한 공연 조회 + // List targetPerformances = seatAllocationService.findBookingOpenPerformances(); + log.debug("좌석 할당 공연 수 : {}", targetPerformances.size()); - if (applicantInfos.isEmpty()) { - log.info("등급 {} - 응모자 없음", grade); - return List.of(); - } + for (DrawTargetPerformance performance : targetPerformances) { + Long scheduleId = performance.performanceScheduleId(); - if (seatCounts == 0) { - log.warn("등급 {} - 좌석 없음, 응모자 {}명은 추첨 불가", grade, applicantInfos.size()); - return List.of(); + try { + seatAllocationService.allocateSeats(scheduleId); + log.debug("공연 추첨 완료 - scheduleId: {}", scheduleId); + } catch (Exception e) { + log.error("공연 추첨 실패 - scheduleId: {}", scheduleId, e); + } } - - // 추첨 진행 + 가중치 - log.info("등급 {} - 추첨 진행, 좌석 : {}", grade, seatCounts); - List winnerIds = drawWithWeight(applicantInfos, seatCounts); - log.info("등급 {} - 당첨자 {}명 선정 완료", grade, winnerIds.size()); - return winnerIds; - - // lotteryResult 생성 (lotteryEntryId, memberId) - } /** - * 가중치 기반 추첨 - 신청 수량이 적을 수록 높은 가중치 부여 - * @param applicantInfos 응모자 리스트 - * @param seatCounts 좌석 수 - * @return 당첨자 id 리스트 + * 미결제자 당첨 취소 */ - private List drawWithWeight(List applicantInfos, Long seatCounts) { - // 가중치 계산 - List weightedApplicants = applicantInfos.stream() - .map(applicant -> new WeightedApplicant( - applicant, - 12 / applicant.quantity()) - ).toList(); - - // 전체 가중치 - int totalWeight = weightedApplicants.stream() - .mapToInt(WeightedApplicant::weight) - .sum(); - - if (totalWeight <= 0) - return List.of(); - - // 추첨 진행 - List winnerIds = new ArrayList<>(); - Set selectedIds = new HashSet<>(); - long remainingSeats = seatCounts; // 남은 좌석 수 - - // 남은 좌석 0 이상, - while (remainingSeats > 0 && selectedIds.size() < applicantInfos.size()) { - int ramdomDraw = ThreadLocalRandom.current().nextInt(totalWeight) + 1; // 0 ~ (totalWeight - 1) - int currentWeight = 0; - - WeightedApplicant selected = null; - - for (WeightedApplicant applicant : weightedApplicants) { - // 기존에 당점된 응모자 생략 - if (selectedIds.contains(applicant.applicantInfo().id())) { - continue; - } - - currentWeight += applicant.weight(); - // 현재 가중치 위치 비교 - if (ramdomDraw <= currentWeight) { - selected = applicant; - break; - } - } - - if (selected == null) { - break; - } - - int requestedQuantity = selected.applicantInfo().quantity(); - - // 신청 수량 확인 후 잔여석 비교 - if (requestedQuantity <= remainingSeats) { - // 당첨자 등록 - winnerIds.add(selected.applicantInfo().id()); - remainingSeats -= requestedQuantity; - } - - // 선택된 응모자 제외 (잔여석 제한 포함) - selectedIds.add(selected.applicantInfo().id()); - totalWeight -= selected.weight(); - - // log.debug("{}drawWithWeight] 당첨: entryId={}, quantity={}, 남은 좌석={}", p, - // selected.applicantInfo().id(), requestedQuantity, remainingSeats); - } - - // log.info("{}drawWithWeight] 추첨 완료 - 당첨자: {}명, 배정 좌석: {}, 남은 좌석: {}", p, - // winnerIds.size(), seatCounts - remainingSeats, remainingSeats); - - return winnerIds; - } - - private EnumMap toSeatCountMap(Long performanceId) { - EnumMap map = new EnumMap<>(SeatGradeType.class); - - // 등급-좌석수 조회 - seatGradeRepository.countSeatGradesByGrade(performanceId) - .forEach(s -> map.put(s.garde(), s.count())); - - for (SeatGradeType g : SeatGradeType.values()) { - map.putIfAbsent(g, 0L); + public void executecancelUnpaid() { + try { + List memberIds = cancelUnpaidService.cancelUnpaid(); + notificationService.notifyCancelUnpaid(memberIds); + } catch (Exception e) { + log.error("당첨 취소 실패"); } - return map; } } diff --git a/src/main/java/com/back/b2st/domain/lottery/draw/service/LotteryNotificationService.java b/src/main/java/com/back/b2st/domain/lottery/draw/service/LotteryNotificationService.java new file mode 100644 index 000000000..3a2eb0751 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/draw/service/LotteryNotificationService.java @@ -0,0 +1,34 @@ +package com.back.b2st.domain.lottery.draw.service; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.back.b2st.domain.email.service.EmailService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LotteryNotificationService { + + private final EmailService emailService; + + public void notifyWinners(Long scheduleId) { + try { + emailService.sendWinnerNotifications(scheduleId); + } catch (Exception e) { + log.error("당첨자 알림 실패 - scheduleId: {}", scheduleId, e); + } + } + + public void notifyCancelUnpaid(List memberIds) { + try { + emailService.sendCancelUnpaidNotifications(memberIds); + } catch (Exception e) { + log.error("당첨 취소 알림 실패", e); + } + } +} diff --git a/src/main/java/com/back/b2st/domain/lottery/draw/service/PerformanceDrawService.java b/src/main/java/com/back/b2st/domain/lottery/draw/service/PerformanceDrawService.java new file mode 100644 index 000000000..ae245f8c1 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/draw/service/PerformanceDrawService.java @@ -0,0 +1,244 @@ +package com.back.b2st.domain.lottery.draw.service; + +import java.security.SecureRandom; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.lottery.draw.dto.LotteryApplicantInfo; +import com.back.b2st.domain.lottery.draw.dto.WeightedApplicant; +import com.back.b2st.domain.lottery.draw.dto.WinnerInfo; +import com.back.b2st.domain.lottery.entry.repository.LotteryEntryRepository; +import com.back.b2st.domain.lottery.result.entity.LotteryResult; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; +import com.back.b2st.domain.performanceschedule.dto.DrawTargetPerformance; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.seat.grade.entity.SeatGradeType; +import com.back.b2st.domain.seat.grade.repository.SeatGradeRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PerformanceDrawService { + + private final PerformanceScheduleRepository performanceScheduleRepository; + private final LotteryEntryRepository lotteryEntryRepository; + private final SeatGradeRepository seatGradeRepository; + private final LotteryResultRepository lotteryResultRepository; + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * 마감 공연 조회 + * @return DrawTargetPerformance = 공연id, 회차id + */ + public List findBookingClosedPerformances() { + LocalDateTime startDate = LocalDate.now().minusDays(1).atStartOfDay(); + LocalDateTime endDate = LocalDate.now().atStartOfDay(); + + return performanceScheduleRepository.findByClosedBetweenAndNotDrawn(startDate, endDate); + } + + public List findBookingClosedPerformances_test() { + return performanceScheduleRepository.findByNotDrawn(); + } + + /** + * 공연 추첨 진행 + * @param performanceId 공연 id + * @param scheduleId 회차 id + */ + @Transactional + protected void drawForPerformance(Long performanceId, Long scheduleId) { + // 응모자 목록 가져오기 + List entryInfos = lotteryEntryRepository.findAppliedInfoByScheduleId( + scheduleId); + + if (entryInfos.size() == 0) { + log.info("공연: {}, 회차: {} - 전체 응모자 없음", performanceId, scheduleId); + return; + } + + // 등급별 좌석 수 + EnumMap seatCountByGrade = toSeatCountMap(performanceId); + + // 등급별 응모자 그룹핑 + EnumMap> byGrade = entryInfos.stream() + .collect(Collectors.groupingBy( + LotteryApplicantInfo::grade, + () -> new EnumMap<>(SeatGradeType.class), + Collectors.toList() + )); + + List allWinnerIds = new ArrayList<>(); + + // 각 등급별 추첨 진행 + for (SeatGradeType gradeType : SeatGradeType.values()) { + allWinnerIds.addAll( + drawByGrade(gradeType, byGrade, seatCountByGrade, scheduleId) + ); + } + + // 추첨 완료 회차 & !WIN => LOSE 일괄 변경 + lotteryEntryRepository.updateStatusBySchedule(scheduleId, allWinnerIds); + performanceScheduleRepository.updateStautsById(scheduleId); + + // 응모 id와 당첨자 id 추출 + List winnerInfos = entryInfos.stream() + .filter(info -> allWinnerIds.contains(info.id())) + .map(info -> new WinnerInfo(info.id(), info.memberId())) + .toList(); + + saveLotteryResult(winnerInfos); + } + + /** + * 당첨자 저장 + * @param winnerInfos 응모id, 사용자 id + */ + private void saveLotteryResult(List winnerInfos) { + List results = new ArrayList<>(); + + for (WinnerInfo info : winnerInfos) { + results.add( + LotteryResult.builder() + .lotteryEntryId(info.id()) + .memberId(info.memberId()) + .build() + ); + } + // todo saveAll 갱신 + lotteryResultRepository.saveAll(results); + } + + /** + * 등급별 추첨 진행 + * @param grade 등급 + * @param byGrade 등급별 응모자 리스트 + * @param seatCountByGrade 등급별 좌석 수 + * @param scheduleId 공연 회차 id + */ + private List drawByGrade( + SeatGradeType grade, + Map> byGrade, + EnumMap seatCountByGrade, + Long scheduleId + ) { + // 해당 등급 응모자 리스트 + List applicantInfos = byGrade.getOrDefault(grade, List.of()); + // 해당 등급 좌석 수 + Long seatCounts = seatCountByGrade.getOrDefault(grade, 0L); + + log.debug("등급: {}, 좌석 수: {}, 응모자 수: {}", grade, seatCounts, applicantInfos.size()); + + if (applicantInfos.isEmpty() || seatCounts == 0) { + log.info("잔여석{} 부족 또는 응모자{} 없음", seatCounts, applicantInfos.size()); + return List.of(); + } + + // 추첨 진행 + 가중치 + log.debug("등급 {} - 추첨 진행, 좌석 : {}", grade, seatCounts); + List winnerIds = drawWithWeight(applicantInfos, seatCounts); + log.debug("등급 {} - 당첨자 {}명 선정 완료", grade, winnerIds.size()); + return winnerIds; + } + + /** + * 가중치 기반 추첨 - 신청 수량이 적을 수록 높은 가중치 부여 + * @param applicantInfos 응모자 리스트 + * @param seatCounts 좌석 수 + * @return 당첨자 id 리스트 + */ + private List drawWithWeight(List applicantInfos, Long seatCounts) { + // 가중치 계산 + List weightedApplicants = applicantInfos.stream() + .map(applicant -> new WeightedApplicant( + applicant, + 12 / applicant.quantity()) + ).toList(); + + // 전체 가중치 + int totalWeight = weightedApplicants.stream() + .mapToInt(WeightedApplicant::weight) + .sum(); + + if (totalWeight <= 0) + return List.of(); + + // 추첨 진행 + List winnerIds = new ArrayList<>(); + Set selectedIds = new HashSet<>(); + long remainingSeats = seatCounts; // 남은 좌석 수 + + // 남은 좌석 0 이상, + while (remainingSeats > 0 && selectedIds.size() < applicantInfos.size()) { + int randomDraw = secureRandom.nextInt(totalWeight) + 1; // 0 ~ (totalWeight - 1) + int currentWeight = 0; + + WeightedApplicant selected = null; + + for (WeightedApplicant applicant : weightedApplicants) { + // 기존에 당점된 응모자 생략 + if (selectedIds.contains(applicant.applicantInfo().id())) { + continue; + } + + currentWeight += applicant.weight(); + // 현재 가중치 위치 비교 + if (randomDraw <= currentWeight) { + selected = applicant; + break; + } + } + + if (selected == null) { + break; + } + + int requestedQuantity = selected.applicantInfo().quantity(); + + // 신청 수량 확인 후 잔여석 비교 + if (requestedQuantity <= remainingSeats) { + // 당첨자 등록 + winnerIds.add(selected.applicantInfo().id()); + remainingSeats -= requestedQuantity; + } + + // 선택된 응모자 제외 (잔여석 제한 포함) + selectedIds.add(selected.applicantInfo().id()); + totalWeight -= selected.weight(); + + log.debug("당첨: entryId={}, quantity={}, 남은 좌석={}", selected.applicantInfo().id(), requestedQuantity, + remainingSeats); + } + + log.debug("추첨 완료 - 당첨자: {}명, 배정 좌석: {}, 남은 좌석: {}", winnerIds.size(), seatCounts - remainingSeats, + remainingSeats); + + return winnerIds; + } + + private EnumMap toSeatCountMap(Long performanceId) { + EnumMap map = new EnumMap<>(SeatGradeType.class); + + // 등급-좌석수 조회 + seatGradeRepository.countSeatGradesByGrade(performanceId) + .forEach(s -> map.put(s.garde(), s.count())); + + for (SeatGradeType g : SeatGradeType.values()) { + map.putIfAbsent(g, 0L); + } + return map; + } +} diff --git a/src/main/java/com/back/b2st/domain/lottery/draw/service/SeatAllocationService.java b/src/main/java/com/back/b2st/domain/lottery/draw/service/SeatAllocationService.java new file mode 100644 index 000000000..55d94b3fb --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/draw/service/SeatAllocationService.java @@ -0,0 +1,136 @@ +package com.back.b2st.domain.lottery.draw.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.lottery.result.dto.LotteryReservationInfo; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; +import com.back.b2st.domain.performanceschedule.dto.DrawTargetPerformance; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; +import com.back.b2st.domain.reservation.service.LotteryReservationService; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.domain.ticket.service.TicketService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SeatAllocationService { + + private final ScheduleSeatRepository scheduleSeatRepository; + private final LotteryResultRepository lotteryResultRepository; + private final LotteryReservationService lotteryReservationService; + private final PerformanceScheduleRepository performanceScheduleRepository; + private final ReservationSeatRepository reservationSeatRepository; + private final TicketService ticketService; + + /** + * 공연 시작 4일 이내인 추첨 공연 조회 - 좌석 배치 미진행 + */ + public List findBookingOpenPerformances() { + LocalDateTime today = LocalDate.now().atStartOfDay(); + LocalDateTime threeDaysLater = LocalDate.now().plusDays(4).atTime(23, 59, 59); + + return performanceScheduleRepository.findByOpenBetween(today, threeDaysLater); + } + + // test + public List findBookingOpenPerformances_test() { + return performanceScheduleRepository.findByOpenBetween(); + } + + /** + * 응모 정보 조회 + */ + public List findReservationInfos(Long scheduleId) { + return lotteryResultRepository.findReservationInfoByPaidIsTrue(scheduleId); + } + + /** + * 예매 생성 여부 조회 + */ + public boolean findReservation(Long reservationId) { + return reservationSeatRepository.existsByReservationId(reservationId); + } + + public void allocateSeats(Long scheduleId) { + // 당첨자 중 결제가 완료된 고객 리스트 조회 + List reservationInfos = findReservationInfos(scheduleId); + + // 좌석할당 + for (LotteryReservationInfo infos : reservationInfos) { + try { + allocateSeatsForLottery(infos); + log.debug("좌석 할당 - reservationId: {}, memberId: {}", infos.reservationId(), infos.memberId()); + } catch (Exception e) { + log.error("좌석 할당 실패 reservationId: {}, memberId: {}", infos.reservationId(), infos.memberId(), e); + } + } + + // 회차 - 좌석배치 완료 + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId).orElseThrow(); + schedule.markSeatAllocated(); + } + + @Transactional + public List allocateSeatsForLottery(LotteryReservationInfo info) { + // 배치 완료된 예매 + if (findReservation(info.reservationId())) { + log.debug("이미 좌석 배치 완료 - reservationId: {}", info.reservationId()); + return List.of(); + } + + List availableSeats = getAvailableSeats(info); + + // 좌석 부족 체크 + if (availableSeats.size() < info.quantity()) { + throw new IllegalStateException( + String.format("좌석 부족 - scheduleId: %d, grade: %s, 필요: %d, 가능: %d", + info.scheduleId(), info.grade(), info.quantity(), availableSeats.size()) + ); + } + + Collections.shuffle(availableSeats); + + // 좌석 배정 + List allocatedSeats = availableSeats.stream() + .limit(info.quantity()) + .toList(); + + List seatsIds = allocatedSeats.stream() + .map(ScheduleSeat::getId) + .toList(); + + // 좌석 확정 + 예매 매핑 + lotteryReservationService.confirmAssignedSeats(info.reservationId(), info.scheduleId(), seatsIds); + + log.info("좌석 배정 완료 - resultId: {}, memberId: {}, 배정 좌석 수: {}", + info.resultId(), info.memberId(), allocatedSeats.size()); + + // 티켓 생성 + for (ScheduleSeat seat : allocatedSeats) { + ticketService.createTicket(info.reservationId(), info.memberId(), seat.getSeatId()); + } + + return allocatedSeats; + } + + /** + * 예매 가능 좌석 조회(AVAILABLE) + */ + private List getAvailableSeats(LotteryReservationInfo info) { + return scheduleSeatRepository.findAvailableSeatsByGrade( + info.scheduleId(), + info.grade()); + } +} diff --git a/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/AppliedLotteryInfo.java b/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/AppliedLotteryInfo.java index 0caf9ba7d..bc8cf243e 100644 --- a/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/AppliedLotteryInfo.java +++ b/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/AppliedLotteryInfo.java @@ -22,6 +22,7 @@ public record AppliedLotteryInfo( LocalDateTime startAt, Integer roundNo, SeatGradeType gradeType, + Integer price, Integer quantity, LotteryStatus status ) { diff --git a/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/SectionLayoutRes.java b/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/SectionLayoutRes.java index 92869ef51..c3ddded30 100644 --- a/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/SectionLayoutRes.java +++ b/src/main/java/com/back/b2st/domain/lottery/entry/dto/response/SectionLayoutRes.java @@ -18,6 +18,7 @@ public record SectionLayoutRes( ) { public record GradeInfo( SeatGradeType grade, + Integer price, List rows ) { } @@ -39,6 +40,7 @@ public static List from(List seats) { List grades = byGrade.entrySet().stream() .map(gradeEntry -> new GradeInfo( gradeEntry.getKey(), + gradeEntry.getValue().get(0).price(), gradeEntry.getValue().stream() .map(SeatInfoRes::rowLabel) .distinct() diff --git a/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryEntry.java b/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryEntry.java index b4c670708..87b24d4bc 100644 --- a/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryEntry.java +++ b/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryEntry.java @@ -106,15 +106,15 @@ public LotteryEntry( this.status = LotteryStatus.APPLIED; } - public void setApplied() { + public void apply() { this.status = LotteryStatus.APPLIED; } - public void setWins() { + public void win() { this.status = LotteryStatus.WIN; } - public void setLose() { + public void lose() { this.status = LotteryStatus.LOSE; } } diff --git a/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryStatus.java b/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryStatus.java index 7895c4e2c..58e39d26e 100644 --- a/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryStatus.java +++ b/src/main/java/com/back/b2st/domain/lottery/entry/entity/LotteryStatus.java @@ -6,5 +6,6 @@ public enum LotteryStatus { APPLIED, WIN, - LOSE + LOSE, + PAID } diff --git a/src/main/java/com/back/b2st/domain/lottery/entry/repository/LotteryEntryRepository.java b/src/main/java/com/back/b2st/domain/lottery/entry/repository/LotteryEntryRepository.java index 620a99db6..28795cdeb 100644 --- a/src/main/java/com/back/b2st/domain/lottery/entry/repository/LotteryEntryRepository.java +++ b/src/main/java/com/back/b2st/domain/lottery/entry/repository/LotteryEntryRepository.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -20,11 +21,13 @@ public interface LotteryEntryRepository extends JpaRepository= :month ORDER BY le.createdAt DESC @@ -37,6 +40,15 @@ Slice findAppliedLotteryByMemberId( List findByScheduleId(Long scheduleId); + @Query(""" + select le.id + from LotteryEntry le + where le.scheduleId in :scheduleIds + """) + List findIdsByScheduleIdIn(@Param("scheduleIds") List scheduleIds); + + void deleteAllByScheduleIdIn(List scheduleIds); + /** * 신청 정보 확인 * @param performanceScheduleId @@ -81,4 +93,7 @@ int updateStatusBySchedule( @Param("scheduleId") Long scheduleId, @Param("winnerIds") List winnerIds ); + + // test + LotteryEntry findByUuid(UUID uuid); } diff --git a/src/main/java/com/back/b2st/domain/lottery/entry/service/LotteryEntryService.java b/src/main/java/com/back/b2st/domain/lottery/entry/service/LotteryEntryService.java index 4da212ede..a6f166358 100644 --- a/src/main/java/com/back/b2st/domain/lottery/entry/service/LotteryEntryService.java +++ b/src/main/java/com/back/b2st/domain/lottery/entry/service/LotteryEntryService.java @@ -3,11 +3,14 @@ import java.time.LocalDateTime; import java.time.Period; import java.util.List; +import java.util.Set; +import java.util.UUID; import org.springframework.dao.DataAccessException; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @@ -16,8 +19,10 @@ import com.back.b2st.domain.lottery.entry.dto.response.LotteryEntryInfo; import com.back.b2st.domain.lottery.entry.dto.response.SectionLayoutRes; import com.back.b2st.domain.lottery.entry.entity.LotteryEntry; +import com.back.b2st.domain.lottery.entry.entity.LotteryStatus; import com.back.b2st.domain.lottery.entry.error.LotteryEntryErrorCode; import com.back.b2st.domain.lottery.entry.repository.LotteryEntryRepository; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; import com.back.b2st.domain.member.repository.MemberRepository; import com.back.b2st.domain.performance.entity.Performance; import com.back.b2st.domain.performance.repository.PerformanceRepository; @@ -45,6 +50,7 @@ public class LotteryEntryService { private final PerformanceRepository performanceRepository; private final SeatService seatService; private final PerformanceScheduleRepository performanceScheduleRepository; + private final LotteryResultRepository lotteryResultRepository; /** * 선택한 회차의 좌석 배치도 전달 @@ -93,7 +99,35 @@ public Slice getMyLotteryEntry(Long memberId, int page) { Pageable pageable = PageRequest.of(page, PAGE_SIZE, Sort.by(Sort.Direction.DESC, "createdAt")); - return lotteryEntryRepository.findAppliedLotteryByMemberId(memberId, fromDate, pageable); + Slice results = lotteryEntryRepository.findAppliedLotteryByMemberId(memberId, fromDate, + pageable); + + List uuids = results.getContent().stream() + .map(AppliedLotteryInfo::lotteryEntryId) + .toList(); + + Set paidUuids = lotteryResultRepository.findPaidByUuids(uuids); + + // 결제 완료된 항목을 PAID로 변경 + List updatedContent = results.getContent().stream() + .map(info -> { + if (paidUuids.contains(info.lotteryEntryId())) { + return new AppliedLotteryInfo( + info.lotteryEntryId(), + info.title(), + info.startAt(), + info.roundNo(), + info.gradeType(), + info.price(), + info.quantity(), + LotteryStatus.PAID + ); + } + return info; + }) + .toList(); + + return new SliceImpl<>(updatedContent, pageable, results.hasNext()); } /** diff --git a/src/main/java/com/back/b2st/domain/lottery/metrics/LotteryMetrics.java b/src/main/java/com/back/b2st/domain/lottery/metrics/LotteryMetrics.java new file mode 100644 index 000000000..9d70f21da --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/metrics/LotteryMetrics.java @@ -0,0 +1,45 @@ +package com.back.b2st.domain.lottery.metrics; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class LotteryMetrics { + private final MeterRegistry registry; + + public LotteryMetrics(MeterRegistry registry) { + this.registry = registry; + } + + /** 추첨 응모 기록 */ + public void recordEntry(Long performanceId, int quantity) { + Counter.builder("lottery_entry_total") + .tag("performance_id", String.valueOf(performanceId)) + .register(registry) + .increment(); + Counter.builder("lottery_entry_quantity_total") + .tag("performance_id", String.valueOf(performanceId)) + .register(registry) + .increment(quantity); + } + + /** 추첨 결과 기록 (당첨/낙첨) */ + public void recordDrawResult(Long performanceId, boolean won) { + Counter.builder("lottery_draw_total") + .tag("performance_id", String.valueOf(performanceId)) + .tag("result", won ? "won" : "lost") + .register(registry) + .increment(); + } + + /** 당첨자 결제 처리 기록 (완료/만료) */ + public void recordWinnerPayment(Long performanceId, boolean completed) { + Counter.builder("lottery_winner_payment_total") + .tag("performance_id", String.valueOf(performanceId)) + .tag("status", completed ? "completed" : "expired") + .register(registry) + .increment(); + } +} diff --git a/src/main/java/com/back/b2st/domain/lottery/result/dto/LotteryReservationInfo.java b/src/main/java/com/back/b2st/domain/lottery/result/dto/LotteryReservationInfo.java new file mode 100644 index 000000000..8b26b6fd3 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/result/dto/LotteryReservationInfo.java @@ -0,0 +1,16 @@ +package com.back.b2st.domain.lottery.result.dto; + +import com.back.b2st.domain.seat.grade.entity.SeatGradeType; + +/** + * 응모 정보 조회 + */ +public record LotteryReservationInfo( + Long reservationId, + Long resultId, + Long memberId, + Long scheduleId, + SeatGradeType grade, + Integer quantity +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/lottery/result/dto/LotteryResultEmailInfo.java b/src/main/java/com/back/b2st/domain/lottery/result/dto/LotteryResultEmailInfo.java new file mode 100644 index 000000000..72a2fca30 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/lottery/result/dto/LotteryResultEmailInfo.java @@ -0,0 +1,15 @@ +package com.back.b2st.domain.lottery.result.dto; + +import java.time.LocalDateTime; + +import com.back.b2st.domain.seat.grade.entity.SeatGradeType; + +public record LotteryResultEmailInfo( + Long id, + Long memberId, + String memberName, // 추가 + SeatGradeType seatGrade, + Integer quantity, + LocalDateTime paymentDeadline // 추가 +) { +} diff --git a/src/main/java/com/back/b2st/domain/lottery/result/entity/LotteryResult.java b/src/main/java/com/back/b2st/domain/lottery/result/entity/LotteryResult.java index 520734bea..815978d38 100644 --- a/src/main/java/com/back/b2st/domain/lottery/result/entity/LotteryResult.java +++ b/src/main/java/com/back/b2st/domain/lottery/result/entity/LotteryResult.java @@ -4,6 +4,8 @@ import java.time.LocalTime; import java.util.UUID; +import org.hibernate.annotations.DynamicUpdate; + import com.back.b2st.global.jpa.entity.BaseEntity; import jakarta.persistence.Column; @@ -31,7 +33,8 @@ @Index(name = "idx_lottery_results_member", columnList = "member_id"), @Index(name = "idx_lottery_results_lottery_entry_member", columnList = "lottery_entry_id, member_id"), @Index(name = "idx_lottery_results_payment_deadline", columnList = "payment_deadline"), - @Index(name = "idx_lottery_results_uuid", columnList = "uuid") + @Index(name = "idx_lottery_results_uuid", columnList = "uuid"), + @Index(name = "idx_lottery_results_paid", columnList = "is_paid") }, uniqueConstraints = { @UniqueConstraint( @@ -49,6 +52,7 @@ sequenceName = "lottery_result_seq", allocationSize = 50 ) +@DynamicUpdate public class LotteryResult extends BaseEntity { public static final int PAYMENT_DEADLINE_DAYS = 2; // TODO : 생성일 + 2일(임시) @@ -91,4 +95,20 @@ public LotteryResult( .plusDays(PAYMENT_DEADLINE_DAYS).with(LocalTime.MAX); this.paid = false; } + + public void confirmPayment() { + this.paid = true; + } + + // test + public static LotteryResult expired( + Long lotteryEntryId, + Long memberId + ) { + LotteryResult result = new LotteryResult(lotteryEntryId, memberId); + result.paymentDeadline = LocalDateTime.now() + .minusDays(1) + .with(LocalTime.MIN); + return result; + } } diff --git a/src/main/java/com/back/b2st/domain/lottery/result/repository/LotteryResultRepository.java b/src/main/java/com/back/b2st/domain/lottery/result/repository/LotteryResultRepository.java index bed813a73..e45209055 100644 --- a/src/main/java/com/back/b2st/domain/lottery/result/repository/LotteryResultRepository.java +++ b/src/main/java/com/back/b2st/domain/lottery/result/repository/LotteryResultRepository.java @@ -1,12 +1,18 @@ package com.back.b2st.domain.lottery.result.repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.back.b2st.domain.lottery.result.dto.LotteryPaymentInfo; +import com.back.b2st.domain.lottery.result.dto.LotteryReservationInfo; +import com.back.b2st.domain.lottery.result.dto.LotteryResultEmailInfo; import com.back.b2st.domain.lottery.result.entity.LotteryResult; public interface LotteryResultRepository extends JpaRepository { @@ -24,6 +30,90 @@ public interface LotteryResultRepository extends JpaRepository findSendEmailInfoByScheduleId( + @Param("scheduleId") Long scheduleId); + + /** + * 좌석 분배를 위한 예매에 필요한 정보 조회 + */ + @Query(""" + select new com.back.b2st.domain.lottery.result.dto.LotteryReservationInfo( + r.id, lr.id, lr.memberId, le.scheduleId, le.grade, le.quantity + ) + FROM LotteryResult lr + JOIN LotteryEntry le ON lr.lotteryEntryId = le.id + JOIN Reservation r ON r.memberId = lr.memberId AND r.scheduleId = le.scheduleId + WHERE lr.paid = true + AND le.scheduleId = :scheduleId + """) + List findReservationInfoByPaidIsTrue(Long scheduleId); + + /* + * 결제 여부 확인 + */ + @Query(""" + SELECT le.uuid + FROM LotteryResult lr + JOIN LotteryEntry le ON lr.lotteryEntryId = le.id + WHERE le.uuid IN :uuids AND lr.paid = true + """) + Set findPaidByUuids(@Param("uuids") List uuids); + + /** + * 기한 내 미결제자 당첨취소 + */ + @Modifying + @Query(""" + DELETE FROM LotteryResult lr + WHERE lr.paymentDeadline < :now + AND lr.paid = false + """) + int removeUnpaidAll(@Param("now") LocalDateTime now); + + @Query(""" + SELECT lr.memberId + FROM LotteryResult lr + WHERE lr.paymentDeadline < :now + AND lr.paid = false + """) + List findCancelUnpaidAll(@Param("now") LocalDateTime now); + + // Test + @Query(""" + SELECT count(*) + FROM LotteryResult lr + WHERE lr.paymentDeadline < :now + AND lr.paid = false + """) + long countUnpaidAll(@Param("now") LocalDateTime now); + + @Query(""" + select lr.id + from LotteryResult lr + where lr.lotteryEntryId in :lotteryEntryIds + """) + List findIdsByLotteryEntryIdIn(@Param("lotteryEntryIds") List lotteryEntryIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from LotteryResult lr + where lr.lotteryEntryId in :lotteryEntryIds + """) + int deleteAllByLotteryEntryIdIn(@Param("lotteryEntryIds") List lotteryEntryIds); } diff --git a/src/main/java/com/back/b2st/domain/member/controller/MemberAdminController.java b/src/main/java/com/back/b2st/domain/member/controller/MemberAdminController.java new file mode 100644 index 000000000..b2a5fef7d --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/controller/MemberAdminController.java @@ -0,0 +1,70 @@ +package com.back.b2st.domain.member.controller; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.member.dto.response.DashboardStatsRes; +import com.back.b2st.domain.member.dto.response.MemberDetailAdminRes; +import com.back.b2st.domain.member.dto.response.MemberSummaryAdminRes; +import com.back.b2st.domain.member.entity.Member; +import com.back.b2st.domain.member.service.MemberAdminService; +import com.back.b2st.global.common.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/members") +@Tag(name = "MemberAdminController", description = "회원 관리 API (관리자 전용)") +@SecurityRequirement(name = "BearerAuth") +public class MemberAdminController { + + private final MemberAdminService memberAdminService; + + /** + * 회원 목록 조회 - 검색 + 필터링 + 페이징 + */ + @GetMapping + @Operation(summary = "회원 목록 조회", description = "검색 조건 설정하여 회원 목록 조회") + public BaseResponse> getMembers( + @Parameter(description = "이메일 검색") @RequestParam(required = false) String email, + @Parameter(description = "이름 검색") @RequestParam(required = false) String name, + @Parameter(description = "권한 필터") @RequestParam(required = false) Member.Role role, + @Parameter(description = "탈퇴 여부") @RequestParam(required = false) Boolean isDeleted, + @Parameter(hidden = true) @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = memberAdminService.getMembers(email, name, role, isDeleted, pageable); + return BaseResponse.success(response); + } + + /** + * 회원 상세 조회 + */ + @GetMapping("/{memberId}") + @Operation(summary = "회원 상세 조회") + public BaseResponse getMemberDetail( + @Parameter(description = "회원 ID", example = "1") @PathVariable Long memberId + ) { + return BaseResponse.success(memberAdminService.getMemberDetail(memberId)); + } + + /** + * 대시보드 통계 조회 + */ + @GetMapping("/dashboard/stats") + @Operation(summary = "대시보드 통계", description = "회원/로그인/보안 통계 데이터") + public BaseResponse getDashboardStats() { + return BaseResponse.success(memberAdminService.getDashboardStats()); + } +} diff --git a/src/main/java/com/back/b2st/domain/member/controller/MemberController.java b/src/main/java/com/back/b2st/domain/member/controller/MemberController.java index 081f59c43..22a5d5dad 100644 --- a/src/main/java/com/back/b2st/domain/member/controller/MemberController.java +++ b/src/main/java/com/back/b2st/domain/member/controller/MemberController.java @@ -1,5 +1,8 @@ package com.back.b2st.domain.member.controller; +import java.util.List; + +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -9,6 +12,7 @@ import com.back.b2st.domain.member.service.MemberService; import com.back.b2st.global.common.BaseResponse; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -17,17 +21,37 @@ @RequiredArgsConstructor public class MemberController { + private static final List IP_HEADERS = List.of("X-Forwarded-For", // 프록시나 로드밸런서 뒤에 있을 때 원래 클라이언트 IP + "X-Real-IP", // Nginx 등에서 설정하는 실제 클라이언트 IP + "Proxy-Client-IP", // Apache 프록시 + "WL-Proxy-Client-IP" // WebLogic 프록시 + ); + private final MemberService memberService; /** * 회원가입 처리 - Bean Validation(정규표현식) + BCrypt 암호화 + 이메일 중복 검사 + 기본 Role 설정 * - * @param request 회원가입 요청 정보 + * @param request 회원가입 요청 정보 + * @param httpRequest HTTP 요청(IP 추출용) * @return 생성된 회원 ID */ @PostMapping("/signup") - public BaseResponse signup(@Valid @RequestBody SignupReq request) { - Long memberId = memberService.signup(request); + public BaseResponse signup(@Valid @RequestBody SignupReq request, HttpServletRequest httpRequest) { + String clientIp = getClientIp(httpRequest); + Long memberId = memberService.signup(request, clientIp); return BaseResponse.created(memberId); } + + /** + * 클라이언트 IP 추출 + */ + private String getClientIp(HttpServletRequest httpRequest) { + return IP_HEADERS.stream() + .map(httpRequest::getHeader) + .filter(StringUtils::hasText) + .map(ip -> ip.split(",")[0].trim()) // 여러 IP가 있을 경우 첫 번째 IP 사용 + .findFirst() + .orElseGet(httpRequest::getRemoteAddr); + } } diff --git a/src/main/java/com/back/b2st/domain/member/controller/MypageController.java b/src/main/java/com/back/b2st/domain/member/controller/MypageController.java index d7e5c8976..8d4cb1682 100644 --- a/src/main/java/com/back/b2st/domain/member/controller/MypageController.java +++ b/src/main/java/com/back/b2st/domain/member/controller/MypageController.java @@ -1,7 +1,6 @@ package com.back.b2st.domain.member.controller; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -64,8 +63,8 @@ public BaseResponse changePassword(@CurrentUser UserPrincipal userPrincipa * @param userPrincipal 현재 로그인한 사용자 정보 * @param request 탈퇴 요청 정보 (비밀번호) */ - @DeleteMapping("/withdraw") - @Operation(summary = "회원 탈퇴", description = "비밀번호 확인 후 탈퇴 처리 (30일간 복구 가능)") + @PostMapping("/withdraw") + @Operation(summary = "회원 탈퇴", description = "일반회원은 비밀번호 확인, 소셜회원은 바로 탈퇴 (30일간 복구 가능)") public BaseResponse withdraw(@CurrentUser UserPrincipal userPrincipal, @Valid @RequestBody WithdrawReq request) { memberService.withdraw(userPrincipal.getId(), request); diff --git a/src/main/java/com/back/b2st/domain/member/dto/event/SignupEvent.java b/src/main/java/com/back/b2st/domain/member/dto/event/SignupEvent.java new file mode 100644 index 000000000..7af4f6008 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/dto/event/SignupEvent.java @@ -0,0 +1,14 @@ +package com.back.b2st.domain.member.dto.event; + +import java.time.LocalDateTime; + +public record SignupEvent( + String email, + String clientIp, + LocalDateTime occurredAt +) { + + public static SignupEvent of(String email, String clientIp) { + return new SignupEvent(email, clientIp, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/back/b2st/domain/member/dto/request/WithdrawReq.java b/src/main/java/com/back/b2st/domain/member/dto/request/WithdrawReq.java index fb52430fb..8e6d93b22 100644 --- a/src/main/java/com/back/b2st/domain/member/dto/request/WithdrawReq.java +++ b/src/main/java/com/back/b2st/domain/member/dto/request/WithdrawReq.java @@ -1,11 +1,12 @@ package com.back.b2st.domain.member.dto.request; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +/** + * 회원 탈퇴 요청 DTO + * - 일반 회원(EMAIL): password 필수 + * - 소셜 회원(KAKAO): password 불필요 (null 허용) + */ public record WithdrawReq( - @NotBlank(message = "비밀번호를 입력해주세요.") - @Size(max = 32, message = "비밀번호가 너무 깁니다.") - String password -) { + @Size(max = 32, message = "비밀번호가 너무 깁니다.") String password) { } diff --git a/src/main/java/com/back/b2st/domain/member/dto/response/DashboardStatsRes.java b/src/main/java/com/back/b2st/domain/member/dto/response/DashboardStatsRes.java new file mode 100644 index 000000000..e858d55da --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/dto/response/DashboardStatsRes.java @@ -0,0 +1,13 @@ +package com.back.b2st.domain.member.dto.response; + +public record DashboardStatsRes( + long totalMembers, + long activeMembers, + long withdrawnMembers, + long adminCount, + long todaySignups, + long todayLogins, + long todayLoginFailures, + int currentLockedAccounts +) { +} diff --git a/src/main/java/com/back/b2st/domain/member/dto/response/MemberDetailAdminRes.java b/src/main/java/com/back/b2st/domain/member/dto/response/MemberDetailAdminRes.java new file mode 100644 index 000000000..0be3fbcce --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/dto/response/MemberDetailAdminRes.java @@ -0,0 +1,42 @@ +package com.back.b2st.domain.member.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.back.b2st.domain.member.entity.Member; + +public record MemberDetailAdminRes( + Long id, + String email, + String name, + String phone, + LocalDate birth, + Member.Role role, + Member.Provider provider, + String providerId, + boolean isEmailVerified, + boolean isIdentityVerified, + boolean isDeleted, + LocalDateTime createdAt, + LocalDateTime modifiedAt, + LocalDateTime deletedAt +) { + public static MemberDetailAdminRes from(Member member) { + return new MemberDetailAdminRes( + member.getId(), + member.getEmail(), + member.getName(), + member.getPhone(), + member.getBirth(), + member.getRole(), + member.getProvider(), + member.getProviderId(), + member.isEmailVerified(), + member.isIdentityVerified(), + member.isDeleted(), + member.getCreatedAt(), + member.getModifiedAt(), + member.getDeletedAt() + ); + } +} diff --git a/src/main/java/com/back/b2st/domain/member/dto/response/MemberSummaryAdminRes.java b/src/main/java/com/back/b2st/domain/member/dto/response/MemberSummaryAdminRes.java new file mode 100644 index 000000000..d3aa255ba --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/dto/response/MemberSummaryAdminRes.java @@ -0,0 +1,33 @@ +package com.back.b2st.domain.member.dto.response; + +import java.time.LocalDateTime; + +import com.back.b2st.domain.member.entity.Member; + +public record MemberSummaryAdminRes( + Long memberId, + String email, + String name, + Member.Role role, + Member.Provider provider, + boolean isEmailVerified, + boolean isIdentityVerified, + boolean isDeleted, + LocalDateTime createdAt, + LocalDateTime deletedAt +) { + public static MemberSummaryAdminRes from(Member member) { + return new MemberSummaryAdminRes( + member.getId(), + member.getEmail(), + member.getName(), + member.getRole(), + member.getProvider(), + member.isEmailVerified(), + member.isIdentityVerified(), + member.isDeleted(), + member.getCreatedAt(), + member.getDeletedAt() + ); + } +} diff --git a/src/main/java/com/back/b2st/domain/member/entity/SignupLog.java b/src/main/java/com/back/b2st/domain/member/entity/SignupLog.java new file mode 100644 index 000000000..3c6ffb718 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/entity/SignupLog.java @@ -0,0 +1,59 @@ +package com.back.b2st.domain.member.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원 가입 시도 로그 + * - 가입 시도/성공 기록 + * - 보안 분석 및 이상 징후 탐지용 + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "signup_logs", indexes = { + @Index(name = "idx_signup_log_email", columnList = "email"), + @Index(name = "idx_signup_log_client_ip", columnList = "clientIp"), + @Index(name = "idx_signup_log_created_at", columnList = "createdAt"), + @Index(name = "idx_signup_log_ip_time", columnList = "clientIp, createdAt") +}) +@SequenceGenerator( + name = "signup_log_id_gen", + sequenceName = "signup_logs_seq", + allocationSize = 50 +) +public class SignupLog { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "signup_log_id_gen") + @Column(name = "signup_log_id") + private Long id; + + @Column(nullable = false, length = 100) + private String email; + + @Column(nullable = false, length = 45) + private String clientIp; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Builder + public SignupLog(String email, String clientIp, LocalDateTime createdAt) { + this.email = email; + this.clientIp = clientIp; + this.createdAt = createdAt != null ? createdAt : LocalDateTime.now(); + } +} diff --git a/src/main/java/com/back/b2st/domain/member/error/MemberErrorCode.java b/src/main/java/com/back/b2st/domain/member/error/MemberErrorCode.java index cf65c9a6f..ec965e640 100644 --- a/src/main/java/com/back/b2st/domain/member/error/MemberErrorCode.java +++ b/src/main/java/com/back/b2st/domain/member/error/MemberErrorCode.java @@ -13,19 +13,18 @@ public enum MemberErrorCode implements ErrorCode { // 회원가입 - Account Enumeration 방지를 위해 모호한 메시지 사용 DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "M401", "요청을 처리할 수 없습니다."), - + // 가입 Rate Limiting + SIGNUP_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "M408", "가입 요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), // 조회 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M403", "해당하는 회원을 찾을 수 없습니다."), - // 비밀번호 변경 PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "M404", "현재 비밀번호가 일치하지 않습니다."), SAME_PASSWORD(HttpStatus.BAD_REQUEST, "M405", "새 비밀번호는 기존 비밀번호와 다르게 설정해야 합니다."), - // 환불 계좌 REFUND_ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "M406", "등록된 환불 계좌가 없습니다."), - // 회원 탈퇴 - ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "M407", "이미 탈퇴한 회원입니다."); + ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "M407", "이미 탈퇴한 회원입니다."), + PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "M409", "비밀번호를 입력해주세요."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/back/b2st/domain/member/listener/SignupEventListener.java b/src/main/java/com/back/b2st/domain/member/listener/SignupEventListener.java new file mode 100644 index 000000000..c0ee04baa --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/listener/SignupEventListener.java @@ -0,0 +1,48 @@ +package com.back.b2st.domain.member.listener; + +import static com.back.b2st.global.util.MaskingUtil.*; + +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.member.dto.event.SignupEvent; +import com.back.b2st.domain.member.entity.SignupLog; +import com.back.b2st.domain.member.repository.SignupLogRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SignupEventListener { + + public final SignupLogRepository signupLogRepository; + + /** + * 회원 가입 이벤트 처리 + * - Async 비동기하고 트랜잭션 메인이랑 분리 + * - try catch는 예외 발생할 때 가입 프로세스에 영향 안 주고 삼키려고 + */ + @Async("signupEventExecutor") + @EventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleSignupEvent(SignupEvent event) { + try { + SignupLog signupLog = SignupLog.builder() + .email(event.email()) + .clientIp(event.clientIp()) + .createdAt(event.occurredAt()) + .build(); + + signupLogRepository.save(signupLog); + + log.info("가입 로그 저장 완료: email={}, IP={}", maskEmail(event.email()), event.clientIp()); + } catch (Exception e) { + log.error("가입 로그 저장 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/back/b2st/domain/member/metrics/MemberMetrics.java b/src/main/java/com/back/b2st/domain/member/metrics/MemberMetrics.java new file mode 100644 index 000000000..61ca0cc6c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/metrics/MemberMetrics.java @@ -0,0 +1,40 @@ +package com.back.b2st.domain.member.metrics; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class MemberMetrics { + + private final Counter signupCounter; + private final Counter withdrawCounter; + private final Counter passwordChangeCounter; + + public MemberMetrics(MeterRegistry registry) { + this.signupCounter = Counter.builder("member_signup_total") + .description("회원가입 횟수") + .register(registry); + + this.withdrawCounter = Counter.builder("member_withdraw_total") + .description("회원탈퇴 횟수") + .register(registry); + + this.passwordChangeCounter = Counter.builder("member_password_change_total") + .description("비밀번호 변경 횟수") + .register(registry); + } + + public void recordSignup() { + signupCounter.increment(); + } + + public void recordWithdraw() { + withdrawCounter.increment(); + } + + public void recordPasswordChange() { + passwordChangeCounter.increment(); + } +} diff --git a/src/main/java/com/back/b2st/domain/member/repository/MemberRepository.java b/src/main/java/com/back/b2st/domain/member/repository/MemberRepository.java index ca9320bf3..e881ecf1b 100644 --- a/src/main/java/com/back/b2st/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/back/b2st/domain/member/repository/MemberRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -20,4 +22,38 @@ public interface MemberRepository extends JpaRepository { List findAllByDeletedAtBefore(@Param("threshold") LocalDateTime threshold); Optional findByProviderId(String providerId); + + /** + * 회원 검색 - 필터링 + 페이징 + */ + @Query(""" + SELECT m FROM Member m + WHERE (:email IS NULL OR m.email LIKE %:email%) + AND (:name IS NULL OR m.name LIKE %:name%) + AND (:role IS NULL OR m.role = :role) + AND (:isDeleted IS NULL\s + OR (:isDeleted = true AND m.deletedAt IS NOT NULL)\s + OR (:isDeleted = false AND m.deletedAt IS NULL)) + ORDER BY m.createdAt DESC + """) + Page searchMembers( + @Param("email") String email, + @Param("name") String name, + @Param("role") Member.Role role, + @Param("isDeleted") Boolean isDeleted, + Pageable pageable + ); + + long countByDeletedAtIsNull(); + + long countByDeletedAtIsNotNull(); + + long countByRole(Member.Role role); + + /** + * 특정 시간 이후의 회원 가입 수 조회 + */ + @Query("SELECT COUNT(m) FROM Member m WHERE m.createdAt >= :since") + long countByCreatedAtAfter(@Param("since") LocalDateTime since); + } diff --git a/src/main/java/com/back/b2st/domain/member/repository/SignupLogRepository.java b/src/main/java/com/back/b2st/domain/member/repository/SignupLogRepository.java new file mode 100644 index 000000000..353f7ea17 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/repository/SignupLogRepository.java @@ -0,0 +1,44 @@ +package com.back.b2st.domain.member.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.back.b2st.domain.member.entity.SignupLog; + +public interface SignupLogRepository extends JpaRepository { + + /** + * 특정 이메일 최근 가입 기록 조회 + */ + List findTop10ByEmailOrderByCreatedAtDesc(String email); + + /** + * 특정 IP에서 일정 시간 내 가입 횟수 조회 + * - 복합 인덱스 + * - 봇, 다중 계정 생성 탐지차원 + */ + @Query("SELECT COUNT(s) FROM SignupLog s " + + "WHERE s.clientIp = :ip AND s.createdAt >= :since") + Long countByClientIpSince(@Param("ip") String clientUp, @Param("since") LocalDateTime since); + + /** + * 특정 IP에서 일정 시간 내 생성된 이메일 목록 조회 + * - 다중 계정 분석 + * - 복합 인덱스 + */ + @Query("SELECT DISTINCT s.email FROM SignupLog s " + + "WHERE s.clientIp = :ip AND s.createdAt >= :since") + List findDistinctEmailsByIpSince(@Param("ip") String clientIp, @Param("since") LocalDateTime since); + + /** + * 회원가입 로그 검색 - 시간 범위 + 페이징 + */ + @Query("SELECT s FROM SignupLog s WHERE s.createdAt >= :since ORDER BY s.createdAt DESC") + Page findByCreatedAtAfter(@Param("since") LocalDateTime since, Pageable pageable); +} diff --git a/src/main/java/com/back/b2st/domain/member/service/MemberAdminService.java b/src/main/java/com/back/b2st/domain/member/service/MemberAdminService.java new file mode 100644 index 000000000..446c76182 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/service/MemberAdminService.java @@ -0,0 +1,69 @@ +package com.back.b2st.domain.member.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.auth.repository.LoginLogRepository; +import com.back.b2st.domain.auth.service.AuthAdminService; +import com.back.b2st.domain.member.dto.response.DashboardStatsRes; +import com.back.b2st.domain.member.dto.response.MemberDetailAdminRes; +import com.back.b2st.domain.member.dto.response.MemberSummaryAdminRes; +import com.back.b2st.domain.member.entity.Member; +import com.back.b2st.domain.member.error.MemberErrorCode; +import com.back.b2st.domain.member.repository.MemberRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class MemberAdminService { + private final MemberRepository memberRepository; + private final LoginLogRepository loginLogRepository; + private final AuthAdminService authAdminService; + + /** + * 회원 목록 조회 + */ + public Page getMembers( + String email, String name, Member.Role role, Boolean isDeleted, Pageable pageable + ) { + return memberRepository.searchMembers(email, name, role, isDeleted, pageable) + .map(MemberSummaryAdminRes::from); + } + + /** + * 회원 상세 조회 + */ + public MemberDetailAdminRes getMemberDetail(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(MemberErrorCode.MEMBER_NOT_FOUND)); + return MemberDetailAdminRes.from(member); + } + + /** + * 대시보드 통계 조회 + */ + public DashboardStatsRes getDashboardStats() { + LocalDateTime todayStart = LocalDateTime.of(LocalDate.now(), LocalTime.MIDNIGHT); + return new DashboardStatsRes( + memberRepository.count(), // 전체 회원 수 + memberRepository.countByDeletedAtIsNull(), // 활성 회원 수 + memberRepository.countByDeletedAtIsNotNull(), // 탈퇴 회원 수 + memberRepository.countByRole(Member.Role.ADMIN), // 관리자 수 + memberRepository.countByCreatedAtAfter(todayStart), // 오늘 가입한 회원 수 + loginLogRepository.countByAttemptedAtAfter(todayStart), // 오늘 로그인 시도 수 + loginLogRepository.countFailuresByAttemptedAtAfter(todayStart), // 오늘 로그인 실패 수 + authAdminService.getLockedAccountCount() // 잠긴 계정 수 + ); + } +} diff --git a/src/main/java/com/back/b2st/domain/member/service/MemberService.java b/src/main/java/com/back/b2st/domain/member/service/MemberService.java index 318cbd3f2..6d3101d5f 100644 --- a/src/main/java/com/back/b2st/domain/member/service/MemberService.java +++ b/src/main/java/com/back/b2st/domain/member/service/MemberService.java @@ -2,17 +2,20 @@ import static com.back.b2st.global.util.MaskingUtil.*; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.back.b2st.domain.auth.repository.RefreshTokenRepository; +import com.back.b2st.domain.member.dto.event.SignupEvent; import com.back.b2st.domain.member.dto.request.PasswordChangeReq; import com.back.b2st.domain.member.dto.request.SignupReq; import com.back.b2st.domain.member.dto.request.WithdrawReq; import com.back.b2st.domain.member.dto.response.MyInfoRes; import com.back.b2st.domain.member.entity.Member; import com.back.b2st.domain.member.error.MemberErrorCode; +import com.back.b2st.domain.member.metrics.MemberMetrics; import com.back.b2st.domain.member.repository.MemberRepository; import com.back.b2st.domain.member.repository.RefundAccountRepository; import com.back.b2st.global.error.exception.BusinessException; @@ -29,15 +32,19 @@ public class MemberService { private final PasswordEncoder passwordEncoder; private final RefreshTokenRepository refreshTokenRepository; private final RefundAccountRepository refundAccountRepository; + private final SignupRateLimitService signupRateLimitService; + private final ApplicationEventPublisher eventPublisher; + private final MemberMetrics memberMetrics; /** - * 회원가입 처리 - 이메일 중복 검사 + BCrypt 암호화 + 기본 Role 설정 + 개인정보 마스킹 로그 + * 회원가입 처리 - 이메일 중복 검사 + BCrypt 암호화 + 기본 Role 설정 + 개인정보 마스킹 로그 + 감사 로그 * * @param request 회원가입 요청 정보 * @return 생성된 회원 ID */ @Transactional - public Long signup(SignupReq request) { + public Long signup(SignupReq request, String clientIp) { + signupRateLimitService.checkSignupLimit(clientIp); validateEmail(request); Member member = Member.builder() @@ -52,9 +59,14 @@ public Long signup(SignupReq request) { .isIdentityVerified(false) .build(); - log.info("새로운 회원 가입: ID={}, Email={}", member.getId(), maskEmail(member.getEmail())); + Member saved = memberRepository.save(member); - return memberRepository.save(member).getId(); + // async 저장 + eventPublisher.publishEvent(SignupEvent.of(saved.getEmail(), clientIp)); + + memberMetrics.recordSignup(); + log.info("새로운 회원 가입: ID={}, Email={}, IP={}", member.getId(), maskEmail(member.getEmail()), clientIp); + return saved.getId(); } /** @@ -82,6 +94,7 @@ public void changePassword(Long memberId, PasswordChangeReq request) { validatePasswordChange(request, member); member.updatePassword(passwordEncoder.encode(request.newPassword())); + memberMetrics.recordPasswordChange(); log.info("비밀번호 변경 완료: MemberID={}", memberId); } @@ -103,6 +116,7 @@ public void withdraw(Long memberId, WithdrawReq request) { member.softDelete(); + memberMetrics.recordWithdraw(); log.info("회원 탈퇴 처리 완료: MemberID={}, Email={}", memberId, maskEmail(member.getEmail())); } diff --git a/src/main/java/com/back/b2st/domain/member/service/SignupRateLimitService.java b/src/main/java/com/back/b2st/domain/member/service/SignupRateLimitService.java new file mode 100644 index 000000000..79c8ce2f8 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/member/service/SignupRateLimitService.java @@ -0,0 +1,100 @@ +package com.back.b2st.domain.member.service; + +import java.time.Duration; +import java.util.List; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import com.back.b2st.domain.auth.metrics.SecurityMetrics; +import com.back.b2st.domain.member.error.MemberErrorCode; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 회원가입 Rate Limiting + * - IP 기반 가입 횟수 제한 + * - 시간당 최대 3회 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SignupRateLimitService { + + private final SecurityMetrics securityMetrics; + + // 세팅 상수 + private static final int MAX_SIGNUPS_PER_HOUR = 3; // 시간당 최대 가입 횟수 + private static final Duration WINDOW_DURATION = Duration.ofHours(1); // 윈도우 기간 + + // Redis 키 접두사 + private static final String SIGNUP_KEY_PREFIX = "signup:ip:"; + + // Lua 스크립트 + private static final String INCREMENT_SCRIPT = "local count = redis.call('INCR', KEYS[1]) " + + "if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end " + // 첫 시도면 만료시간 설정 + "return count"; + + private final StringRedisTemplate redisTemplate; + private final DefaultRedisScript incrementScript = createIncrementScript(); + + private DefaultRedisScript createIncrementScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(INCREMENT_SCRIPT); + script.setResultType(Long.class); + return script; + } + + /** + * 가입 Rate Limit 검사 + * - IP당 시간당 3회 + * + * @param clientIp 클라이언트 IP 주소 + */ + public void checkSignupLimit(String clientIp) { + String key = SIGNUP_KEY_PREFIX + clientIp; + Long count = redisTemplate.execute( + incrementScript, + List.of(key), + String.valueOf(WINDOW_DURATION.getSeconds())); + + if (count == null) { + count = 1L; + } + + log.debug("가입 시도: IP={}, count={}/{}", clientIp, count, MAX_SIGNUPS_PER_HOUR); + + // 최대 횟수 초과 시 예외 + if (count > MAX_SIGNUPS_PER_HOUR) { + securityMetrics.recordRateLimitTriggered("/api/members/signup"); + log.warn("가입 시도 초과: IP={}, count={}", clientIp, count); + throw new BusinessException(MemberErrorCode.SIGNUP_RATE_LIMIT_EXCEEDED); + } + } + + /** + * 현재 가입 시도 횟수 조회 (테스트/모니터링 용도) + * + * @param clientIp 클라이언트 IP + * @return 현재 시도 횟수 (없으면 0) + */ + public int getSignupAttemptCount(String clientIp) { + String key = SIGNUP_KEY_PREFIX + clientIp; + String value = redisTemplate.opsForValue().get(key); + return value != null ? Integer.parseInt(value) : 0; + } + + /** + * 남은 가입 횟수 조회 + * + * @param clientIp 클라이언트 IP + * @return 남은 횟수 + */ + public int getRemainingSignupAttempts(String clientIp) { + return Math.max(0, MAX_SIGNUPS_PER_HOUR - getSignupAttemptCount(clientIp)); + } + +} diff --git a/src/main/java/com/back/b2st/domain/payment/controller/PaymentController.java b/src/main/java/com/back/b2st/domain/payment/controller/PaymentController.java index 20288b7c5..c630ecbac 100644 --- a/src/main/java/com/back/b2st/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/back/b2st/domain/payment/controller/PaymentController.java @@ -1,26 +1,15 @@ package com.back.b2st.domain.payment.controller; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.back.b2st.domain.payment.dto.request.PaymentCancelReq; -import com.back.b2st.domain.payment.dto.request.PaymentConfirmReq; -import com.back.b2st.domain.payment.dto.request.PaymentFailReq; -import com.back.b2st.domain.payment.dto.request.PaymentPrepareReq; -import com.back.b2st.domain.payment.dto.response.PaymentCancelRes; +import com.back.b2st.domain.payment.dto.request.PaymentPayReq; import com.back.b2st.domain.payment.dto.response.PaymentConfirmRes; -import com.back.b2st.domain.payment.dto.response.PaymentFailRes; -import com.back.b2st.domain.payment.dto.response.PaymentPrepareRes; import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.payment.service.PaymentCancelService; -import com.back.b2st.domain.payment.service.PaymentConfirmService; -import com.back.b2st.domain.payment.service.PaymentFailService; -import com.back.b2st.domain.payment.service.PaymentPrepareService; -import com.back.b2st.domain.reservation.service.ReservationService; +import com.back.b2st.domain.payment.service.PaymentOneClickService; import com.back.b2st.global.annotation.CurrentUser; import com.back.b2st.global.common.BaseResponse; import com.back.b2st.security.UserPrincipal; @@ -37,66 +26,21 @@ @RequestMapping("/api/payments") public class PaymentController { - private final PaymentPrepareService paymentPrepareService; - private final PaymentConfirmService paymentConfirmService; - private final PaymentCancelService paymentCancelService; - private final PaymentFailService paymentFailService; - - private final ReservationService reservationService; + private final PaymentOneClickService paymentOneClickService; @Operation( - summary = "결제 준비", - description = "결제 정보를 생성하고 orderId를 반환합니다." + summary = "원클릭 결제 (PG 미사용)", + description = "결제 준비/승인/도메인 후처리를 한 번에 수행합니다.\n\n" + + "- LOTTERY: entryId(UUID) 필수\n" + + "- LOTTERY 결제 후처리: 좌석/티켓 즉시 생성하지 않고 paid=true만 마킹(좌석 배정은 배치에서 처리)\n" + + "- 그 외: domainId(Long) 필수" ) - @PostMapping("/prepare") - public ResponseEntity> prepare( + @PostMapping("/pay") + public ResponseEntity> pay( @Parameter(hidden = true) @CurrentUser UserPrincipal user, - @Valid @RequestBody PaymentPrepareReq request + @Valid @RequestBody PaymentPayReq request ) { - Payment payment = paymentPrepareService.prepare(user.getId(), request); - return ResponseEntity.ok(BaseResponse.created(PaymentPrepareRes.from(payment))); - } - - @Operation( - summary = "결제 승인", - description = "결제를 승인하고 도메인별 후처리를 수행합니다." - ) - @PostMapping("/confirm") - public ResponseEntity> confirm( - @Parameter(hidden = true) @CurrentUser UserPrincipal user, - @Valid @RequestBody PaymentConfirmReq request - ) { - Payment payment = paymentConfirmService.confirm(user.getId(), request); + Payment payment = paymentOneClickService.pay(user.getId(), request); return ResponseEntity.ok(BaseResponse.success(PaymentConfirmRes.from(payment))); } - - @Operation( - summary = "결제 취소", - description = "완료된 결제를 취소합니다.\n\n" - + "- 티켓 거래(TRADE) 결제는 취소/환불을 지원하지 않습니다.\n" - + "- 예매(RESERVATION) 결제는 취소/환불을 지원하지 않습니다." - ) - @PostMapping("/{orderId}/cancel") - public ResponseEntity> cancel( - @Parameter(description = "주문 ID", example = "ORDER-123") @PathVariable("orderId") String orderId, - @Valid @RequestBody PaymentCancelReq request, - @Parameter(hidden = true) @CurrentUser UserPrincipal user - ) { - Payment canceledPayment = paymentCancelService.cancel(user.getId(), orderId, request); - return ResponseEntity.ok(BaseResponse.success(PaymentCancelRes.from(canceledPayment))); - } - - @Operation( - summary = "결제 실패 처리", - description = "결제 실패 시 호출되며, 도메인별 실패 후처리를 수행합니다." - ) - @PostMapping("/{orderId}/fail") - public ResponseEntity> fail( - @Parameter(description = "주문 ID", example = "ORDER-123") @PathVariable("orderId") String orderId, - @Valid @RequestBody PaymentFailReq request, - @Parameter(hidden = true) @CurrentUser UserPrincipal user - ) { - Payment payment = paymentFailService.fail(user.getId(), orderId, request.reason()); - return ResponseEntity.ok(BaseResponse.success(PaymentFailRes.from(payment))); - } } diff --git a/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentCancelReq.java b/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentCancelReq.java deleted file mode 100644 index 060dbdd15..000000000 --- a/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentCancelReq.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.back.b2st.domain.payment.dto.request; - -import jakarta.validation.constraints.NotBlank; - -public record PaymentCancelReq( - @NotBlank(message = "취소 사유는 필수입니다") - String reason -) { -} diff --git a/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentConfirmReq.java b/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentConfirmReq.java deleted file mode 100644 index 68e35da05..000000000 --- a/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentConfirmReq.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.back.b2st.domain.payment.dto.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.NotBlank; - -public record PaymentConfirmReq( - @NotBlank String orderId, - @NotNull Long amount -) { -} diff --git a/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentFailReq.java b/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentFailReq.java deleted file mode 100644 index 4c34d364e..000000000 --- a/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentFailReq.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.back.b2st.domain.payment.dto.request; - -import jakarta.validation.constraints.NotBlank; - -public record PaymentFailReq( - @NotBlank String reason -) { -} - diff --git a/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentPayReq.java b/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentPayReq.java new file mode 100644 index 000000000..839d90144 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/dto/request/PaymentPayReq.java @@ -0,0 +1,17 @@ +package com.back.b2st.domain.payment.dto.request; + +import java.util.UUID; + +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.entity.PaymentMethod; + +import jakarta.validation.constraints.NotNull; + +public record PaymentPayReq( + @NotNull DomainType domainType, + @NotNull PaymentMethod paymentMethod, + Long domainId, + UUID entryId +) { +} + diff --git a/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentCancelRes.java b/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentCancelRes.java deleted file mode 100644 index 8e91ce27e..000000000 --- a/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentCancelRes.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.back.b2st.domain.payment.dto.response; - -import java.time.LocalDateTime; - -import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.payment.entity.PaymentStatus; - -public record PaymentCancelRes( - String orderId, - Long amount, - PaymentStatus status, - LocalDateTime canceledAt, - String failureReason -) { - public static PaymentCancelRes from(Payment payment) { - return new PaymentCancelRes( - payment.getOrderId(), - payment.getAmount(), - payment.getStatus(), - payment.getCanceledAt(), - payment.getFailureReason() - ); - } -} diff --git a/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentFailRes.java b/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentFailRes.java deleted file mode 100644 index 04de66bb9..000000000 --- a/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentFailRes.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.back.b2st.domain.payment.dto.response; - -import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.payment.entity.PaymentStatus; - -public record PaymentFailRes( - String orderId, - Long amount, - PaymentStatus status, - String failureReason -) { - public static PaymentFailRes from(Payment payment) { - return new PaymentFailRes( - payment.getOrderId(), - payment.getAmount(), - payment.getStatus(), - payment.getFailureReason() - ); - } -} - diff --git a/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentPrepareRes.java b/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentPrepareRes.java deleted file mode 100644 index 9f6a76bb4..000000000 --- a/src/main/java/com/back/b2st/domain/payment/dto/response/PaymentPrepareRes.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.back.b2st.domain.payment.dto.response; - -import java.time.LocalDateTime; - -import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.payment.entity.PaymentStatus; - -public record PaymentPrepareRes( - Long paymentId, - String orderId, - Long amount, - PaymentStatus status, - LocalDateTime expiresAt -) { - public static PaymentPrepareRes from(Payment payment) { - return new PaymentPrepareRes( - payment.getId(), - payment.getOrderId(), - payment.getAmount(), - payment.getStatus(), - payment.getExpiresAt() - ); - } -} diff --git a/src/main/java/com/back/b2st/domain/payment/entity/DomainType.java b/src/main/java/com/back/b2st/domain/payment/entity/DomainType.java index 39b600fa8..3fbdfddcc 100644 --- a/src/main/java/com/back/b2st/domain/payment/entity/DomainType.java +++ b/src/main/java/com/back/b2st/domain/payment/entity/DomainType.java @@ -7,6 +7,7 @@ @RequiredArgsConstructor public enum DomainType { RESERVATION("일반 예매"), + PRERESERVATION("신청 예매"), LOTTERY("추첨 예매"), TRADE("티켓 거래"); diff --git a/src/main/java/com/back/b2st/domain/payment/metrics/PaymentMetrics.java b/src/main/java/com/back/b2st/domain/payment/metrics/PaymentMetrics.java new file mode 100644 index 000000000..f480267d5 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/metrics/PaymentMetrics.java @@ -0,0 +1,76 @@ +package com.back.b2st.domain.payment.metrics; + +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.entity.PaymentMethod; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; + +@Component +public class PaymentMetrics { + private final MeterRegistry registry; + private final Timer paymentProcessTimer; + private final DistributionSummary paymentAmountSummary; + + public PaymentMetrics(MeterRegistry registry) { + this.registry = registry; + + this.paymentProcessTimer = Timer.builder("payment_process_duration") + .description("결제 처리 소요 시간") + .publishPercentiles(0.5, 0.95, 0.99) + .register(registry); + + this.paymentAmountSummary = DistributionSummary.builder("payment_amount") + .description("결제 금액 분포") + .baseUnit("won") + .publishPercentiles(0.5, 0.95, 0.99) + .register(registry); + } + + /** 결제 성공 기록 */ + public void recordPaymentSuccess(DomainType domainType, PaymentMethod method, int amount) { + Counter.builder("payment_total") + .tag("result", "success") + .tag("domain_type", domainType.name()) + .tag("method", method.name()) + .register(registry) + .increment(); + paymentAmountSummary.record(amount); + } + + /** 결제 실패 기록 */ + public void recordPaymentFailure(DomainType domainType, String reason) { + Counter.builder("payment_total") + .tag("result", "failure") + .tag("domain_type", domainType.name()) + .tag("reason", reason) + .register(registry) + .increment(); + } + + /** 환불 처리 기록 */ + public void recordRefund(DomainType domainType, int amount) { + Counter.builder("payment_refund_total") + .tag("domain_type", domainType.name()) + .register(registry) + .increment(); + Counter.builder("payment_refund_amount_total") + .tag("domain_type", domainType.name()) + .register(registry) + .increment(amount); + } + + /** 결제 처리 시간 측정 시작 */ + public Timer.Sample startPaymentTimer() { + return Timer.start(registry); + } + + /** 결제 처리 시간 측정 종료 */ + public void stopPaymentTimer(Timer.Sample sample) { + sample.stop(paymentProcessTimer); + } +} diff --git a/src/main/java/com/back/b2st/domain/payment/repository/PaymentRepository.java b/src/main/java/com/back/b2st/domain/payment/repository/PaymentRepository.java index 63e7f81cd..3b1b25199 100644 --- a/src/main/java/com/back/b2st/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/back/b2st/domain/payment/repository/PaymentRepository.java @@ -90,4 +90,15 @@ Optional findByDomainTypeAndDomainIdAndMemberId( Long domainId, Long memberId ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from Payment p + where p.domainType = :domainType + and p.domainId in :domainIds + """) + int deleteAllByDomainTypeAndDomainIdIn( + @Param("domainType") DomainType domainType, + @Param("domainIds") List domainIds + ); } diff --git a/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentFinalizer.java b/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentFinalizer.java new file mode 100644 index 000000000..e3519a5b7 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentFinalizer.java @@ -0,0 +1,56 @@ +package com.back.b2st.domain.payment.service; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.lottery.entry.entity.LotteryEntry; +import com.back.b2st.domain.lottery.result.entity.LotteryResult; +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.entity.Payment; +import com.back.b2st.domain.payment.error.PaymentErrorCode; +import com.back.b2st.domain.reservation.service.LotteryReservationService; +import com.back.b2st.global.error.exception.BusinessException; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LotteryPaymentFinalizer implements PaymentFinalizer { + + private final LotteryReservationService lotteryReservationService; + private final EntityManager entityManager; + + @Override + public boolean supports(DomainType domainType) { + return domainType == DomainType.LOTTERY; + } + + @Override + @Transactional + public void finalizePayment(Payment payment) { + LotteryResult lotteryResult = entityManager.find(LotteryResult.class, payment.getDomainId(), + LockModeType.PESSIMISTIC_WRITE); + + if (lotteryResult == null) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND); + } + + if (!lotteryResult.getMemberId().equals(payment.getMemberId())) { + throw new BusinessException(PaymentErrorCode.UNAUTHORIZED_PAYMENT_ACCESS); + } + + // 결제 완료 마킹만 수행 (좌석/티켓 생성은 추후 배치에서 처리) + if (!lotteryResult.isPaid()) { + lotteryResult.confirmPayment(); + } + + LotteryEntry lotteryEntry = entityManager.find(LotteryEntry.class, lotteryResult.getLotteryEntryId()); + if (lotteryEntry == null) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND); + } + + lotteryReservationService.getOrCreateCompletedReservation(payment.getMemberId(), lotteryEntry.getScheduleId()); + } +} diff --git a/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentHandler.java b/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentHandler.java new file mode 100644 index 000000000..feb40d934 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentHandler.java @@ -0,0 +1,67 @@ +package com.back.b2st.domain.payment.service; + +import java.time.Clock; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.lottery.entry.entity.LotteryEntry; +import com.back.b2st.domain.lottery.entry.repository.LotteryEntryRepository; +import com.back.b2st.domain.lottery.result.entity.LotteryResult; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.error.PaymentErrorCode; +import com.back.b2st.domain.seat.grade.entity.SeatGrade; +import com.back.b2st.domain.seat.grade.repository.SeatGradeRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LotteryPaymentHandler implements PaymentDomainHandler { + + private final LotteryResultRepository lotteryResultRepository; + private final LotteryEntryRepository lotteryEntryRepository; + private final SeatGradeRepository seatGradeRepository; + private final Clock clock; + + @Override + public boolean supports(DomainType domainType) { + return domainType == DomainType.LOTTERY; + } + + @Override + @Transactional(readOnly = true) + public PaymentTarget loadAndValidate(Long lotteryResultId, Long memberId) { + LotteryResult lotteryResult = lotteryResultRepository.findById(lotteryResultId) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + if (!lotteryResult.getMemberId().equals(memberId)) { + throw new BusinessException(PaymentErrorCode.UNAUTHORIZED_PAYMENT_ACCESS); + } + + if (lotteryResult.isPaid()) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); + } + + LocalDateTime now = LocalDateTime.now(clock); + if (now.isAfter(lotteryResult.getPaymentDeadline())) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "결제 가능 시간이 만료되었습니다."); + } + + LotteryEntry lotteryEntry = lotteryEntryRepository.findById(lotteryResult.getLotteryEntryId()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + SeatGrade seatGrade = seatGradeRepository.findTopByPerformanceIdAndGradeOrderByIdDesc( + lotteryEntry.getPerformanceId(), + lotteryEntry.getGrade() + ) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + Long expectedAmount = seatGrade.getPrice().longValue() * lotteryEntry.getQuantity(); + return new PaymentTarget(DomainType.LOTTERY, lotteryResultId, expectedAmount); + } +} + diff --git a/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentPrepareService.java b/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentPrepareService.java new file mode 100644 index 000000000..721d67ec1 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/service/LotteryPaymentPrepareService.java @@ -0,0 +1,43 @@ +package com.back.b2st.domain.payment.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.lottery.result.dto.LotteryPaymentInfo; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; +import com.back.b2st.domain.payment.dto.request.PaymentPrepareReq; +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.entity.Payment; +import com.back.b2st.domain.payment.entity.PaymentMethod; +import com.back.b2st.domain.payment.error.PaymentErrorCode; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LotteryPaymentPrepareService { + + private final LotteryResultRepository lotteryResultRepository; + private final PaymentPrepareService paymentPrepareService; + + @Transactional + public Payment prepareByEntryUuid(Long memberId, UUID entryUuid, PaymentMethod paymentMethod) { + LotteryPaymentInfo info = lotteryResultRepository.findPaymentInfoById(entryUuid); + if (info == null) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND); + } + + if (!info.memberId().equals(memberId)) { + throw new BusinessException(PaymentErrorCode.UNAUTHORIZED_PAYMENT_ACCESS); + } + + return paymentPrepareService.prepare( + memberId, + new PaymentPrepareReq(DomainType.LOTTERY, info.id(), paymentMethod) + ); + } +} + diff --git a/src/main/java/com/back/b2st/domain/payment/service/PaymentCancelService.java b/src/main/java/com/back/b2st/domain/payment/service/PaymentCancelService.java deleted file mode 100644 index 3894c3952..000000000 --- a/src/main/java/com/back/b2st/domain/payment/service/PaymentCancelService.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.back.b2st.domain.payment.service; - -import java.time.Clock; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.back.b2st.domain.payment.dto.request.PaymentCancelReq; -import com.back.b2st.domain.payment.entity.DomainType; -import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.payment.entity.PaymentStatus; -import com.back.b2st.domain.payment.error.PaymentErrorCode; -import com.back.b2st.domain.payment.repository.PaymentRepository; -import com.back.b2st.global.error.exception.BusinessException; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class PaymentCancelService { - - private final PaymentRepository paymentRepository; - private final Clock clock; - - @Transactional - public Payment cancel(Long memberId, String orderId, PaymentCancelReq request) { - // 1. 결제 조회 - Payment payment = paymentRepository.findByOrderId(orderId) - .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)); - - // 2. 권한 검증 (본인 결제만 취소 가능) - payment.validateOwner(memberId); - - // 3. 멱등성 처리: 이미 취소된 경우 - if (payment.getStatus() == PaymentStatus.CANCELED) { - return payment; - } - - // 4. DONE 상태만 취소 가능 - if (payment.getStatus() != PaymentStatus.DONE) { - throw new BusinessException(PaymentErrorCode.INVALID_STATUS, - "완료된 결제만 취소할 수 있습니다."); - } - - // 5. 정책: 티켓 거래 결제는 취소/환불 미지원 - if (payment.getDomainType() == DomainType.TRADE) { - throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, - "티켓 거래 결제는 취소/환불을 지원하지 않습니다."); - } - - // 6. 정책: 예매 결제는 취소/환불 미지원 (결제 완료 후 예매 취소 불가) - if (payment.getDomainType() == DomainType.RESERVATION) { - throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, - "예매 결제는 취소/환불을 지원하지 않습니다."); - } - - // 7. 현재 지원하지 않는 도메인인 경우 - throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, - "결제 취소를 지원하지 않는 도메인입니다."); - } -} diff --git a/src/main/java/com/back/b2st/domain/payment/service/PaymentConfirmService.java b/src/main/java/com/back/b2st/domain/payment/service/PaymentConfirmService.java deleted file mode 100644 index d55388ebe..000000000 --- a/src/main/java/com/back/b2st/domain/payment/service/PaymentConfirmService.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.back.b2st.domain.payment.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.back.b2st.domain.payment.dto.request.PaymentConfirmReq; -import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.payment.entity.PaymentStatus; -import com.back.b2st.domain.payment.error.PaymentErrorCode; -import com.back.b2st.domain.payment.repository.PaymentRepository; -import com.back.b2st.global.error.exception.BusinessException; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class PaymentConfirmService { - - private final PaymentRepository paymentRepository; - private final PaymentConfirmTransactionService paymentConfirmTransactionService; - private final PaymentFinalizeService paymentFinalizeService; - - @Transactional - public Payment confirm(Long memberId, PaymentConfirmReq request) { - Payment payment = paymentRepository.findByOrderId(request.orderId()) - .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)); - - payment.validateOwner(memberId); - payment.validateAmount(request.amount()); - - if (payment.getStatus() == PaymentStatus.DONE) { - paymentFinalizeService.finalizeByOrderId(request.orderId()); - return payment; - } - - if (payment.getStatus() != PaymentStatus.READY - && payment.getStatus() != PaymentStatus.WAITING_FOR_DEPOSIT) { - throw new BusinessException(PaymentErrorCode.INVALID_STATUS); - } - - paymentConfirmTransactionService.completeIdempotently(request.orderId()); - - paymentFinalizeService.finalizeByOrderId(request.orderId()); - - return paymentRepository.findByOrderId(request.orderId()) - .map(confirmed -> { - confirmed.validateOwner(memberId); - return confirmed; - }) - .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)); - } -} diff --git a/src/main/java/com/back/b2st/domain/payment/service/PaymentFailService.java b/src/main/java/com/back/b2st/domain/payment/service/PaymentFailService.java deleted file mode 100644 index 5f6f3624a..000000000 --- a/src/main/java/com/back/b2st/domain/payment/service/PaymentFailService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.back.b2st.domain.payment.service; - -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.payment.entity.PaymentStatus; -import com.back.b2st.domain.payment.error.PaymentErrorCode; -import com.back.b2st.domain.payment.repository.PaymentRepository; -import com.back.b2st.global.error.exception.BusinessException; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class PaymentFailService { - - private final PaymentRepository paymentRepository; - private final List failureHandlers; - - @Transactional - public Payment fail(Long memberId, String orderId, String reason) { - Payment payment = paymentRepository.findByOrderId(orderId) - .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)); - - payment.validateOwner(memberId); - - // 멱등: 이미 실패 처리된 경우에도 도메인 후처리를 재시도할 수 있도록 허용 - if (payment.getStatus() == PaymentStatus.FAILED) { - handleDomainFailure(payment); - return payment; - } - - // READY에서만 FAILED로 전이 가능 (Payment.validateTransition 내에서도 방어) - if (payment.getStatus() != PaymentStatus.READY) { - throw new BusinessException(PaymentErrorCode.INVALID_STATUS, "결제 실패 처리가 불가능한 상태입니다."); - } - - payment.fail(reason); - handleDomainFailure(payment); - - return payment; - } - - private void handleDomainFailure(Payment payment) { - failureHandlers.stream() - .filter(h -> h.supports(payment.getDomainType())) - .findFirst() - .ifPresent(h -> h.handleFailure(payment)); - } -} diff --git a/src/main/java/com/back/b2st/domain/payment/service/PaymentFailureHandler.java b/src/main/java/com/back/b2st/domain/payment/service/PaymentFailureHandler.java deleted file mode 100644 index 6e87659eb..000000000 --- a/src/main/java/com/back/b2st/domain/payment/service/PaymentFailureHandler.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.back.b2st.domain.payment.service; - -import com.back.b2st.domain.payment.entity.DomainType; -import com.back.b2st.domain.payment.entity.Payment; - -/** - * 결제 실패 시 도메인별 후처리를 담당하는 핸들러 인터페이스 - */ -public interface PaymentFailureHandler { - - boolean supports(DomainType domainType); - - void handleFailure(Payment payment); -} - diff --git a/src/main/java/com/back/b2st/domain/payment/service/PaymentOneClickService.java b/src/main/java/com/back/b2st/domain/payment/service/PaymentOneClickService.java new file mode 100644 index 000000000..8b7428d3c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/service/PaymentOneClickService.java @@ -0,0 +1,57 @@ +package com.back.b2st.domain.payment.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.payment.dto.request.PaymentPayReq; +import com.back.b2st.domain.payment.dto.request.PaymentPrepareReq; +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.entity.Payment; +import com.back.b2st.domain.payment.error.PaymentErrorCode; +import com.back.b2st.domain.payment.repository.PaymentRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PaymentOneClickService { + + private final PaymentPrepareService paymentPrepareService; + private final LotteryPaymentPrepareService lotteryPaymentPrepareService; + private final PaymentConfirmTransactionService paymentConfirmTransactionService; + private final PaymentFinalizeService paymentFinalizeService; + private final PaymentRepository paymentRepository; + + @Transactional + public Payment pay(Long memberId, PaymentPayReq request) { + if (request.domainType() == DomainType.LOTTERY) { + if (request.entryId() == null) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND, "추첨 응모 ID(entryId)가 필요합니다."); + } + Payment payment = lotteryPaymentPrepareService.prepareByEntryUuid( + memberId, + request.entryId(), + request.paymentMethod() + ); + return completeAndFinalize(payment.getOrderId()); + } + + if (request.domainId() == null) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND, "결제 대상 ID(domainId)가 필요합니다."); + } + + Payment payment = paymentPrepareService.prepare( + memberId, + new PaymentPrepareReq(request.domainType(), request.domainId(), request.paymentMethod()) + ); + return completeAndFinalize(payment.getOrderId()); + } + + private Payment completeAndFinalize(String orderId) { + paymentConfirmTransactionService.completeIdempotently(orderId); + paymentFinalizeService.finalizeByOrderId(orderId); + return paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)); + } +} diff --git a/src/main/java/com/back/b2st/domain/payment/service/PaymentViewService.java b/src/main/java/com/back/b2st/domain/payment/service/PaymentViewService.java index 9be4768dd..f5b89c63a 100644 --- a/src/main/java/com/back/b2st/domain/payment/service/PaymentViewService.java +++ b/src/main/java/com/back/b2st/domain/payment/service/PaymentViewService.java @@ -17,11 +17,12 @@ public class PaymentViewService { @Transactional(readOnly = true) public PaymentConfirmRes getByReservationId(Long reservationId, Long memberId) { - return paymentRepository.findByDomainTypeAndDomainIdAndMemberId( - DomainType.RESERVATION, - reservationId, - memberId - ) + return getByDomain(DomainType.RESERVATION, reservationId, memberId); + } + + @Transactional(readOnly = true) + public PaymentConfirmRes getByDomain(DomainType domainType, Long domainId, Long memberId) { + return paymentRepository.findByDomainTypeAndDomainIdAndMemberId(domainType, domainId, memberId) .map(PaymentConfirmRes::from) .orElse(null); } diff --git a/src/main/java/com/back/b2st/domain/payment/service/PrereservationPaymentFinalizer.java b/src/main/java/com/back/b2st/domain/payment/service/PrereservationPaymentFinalizer.java new file mode 100644 index 000000000..b9670b5b0 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/service/PrereservationPaymentFinalizer.java @@ -0,0 +1,169 @@ +package com.back.b2st.domain.payment.service; + +import java.time.Clock; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.entity.Payment; +import com.back.b2st.domain.payment.error.PaymentErrorCode; +import com.back.b2st.domain.performanceschedule.entity.BookingType; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBooking; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBookingStatus; +import com.back.b2st.domain.prereservation.booking.repository.PrereservationBookingRepository; +import com.back.b2st.domain.reservation.entity.Reservation; +import com.back.b2st.domain.reservation.entity.ReservationSeat; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.domain.ticket.service.TicketService; +import com.back.b2st.global.error.exception.BusinessException; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PrereservationPaymentFinalizer implements PaymentFinalizer { + + private final EntityManager entityManager; + private final PerformanceScheduleRepository performanceScheduleRepository; + private final PrereservationBookingRepository prereservationBookingRepository; + private final TicketService ticketService; + private final Clock clock; + + @Override + public boolean supports(DomainType domainType) { + return domainType == DomainType.PRERESERVATION; + } + + @Override + @Transactional + public void finalizePayment(Payment payment) { + PrereservationBooking booking = prereservationBookingRepository.findByIdWithLock(payment.getDomainId()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + if (!booking.getMemberId().equals(payment.getMemberId())) { + throw new BusinessException(PaymentErrorCode.UNAUTHORIZED_PAYMENT_ACCESS); + } + + if (booking.getStatus() == PrereservationBookingStatus.CANCELED) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "이미 취소된 신청예매입니다."); + } + + PerformanceSchedule schedule = performanceScheduleRepository.findById(booking.getScheduleId()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + if (schedule.getBookingType() != BookingType.PRERESERVE) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "신청 예매 결제 대상이 아닙니다."); + } + + ScheduleSeat scheduleSeat = findScheduleSeatWithLock(booking.getScheduleSeatId()); + + if (booking.getStatus() == PrereservationBookingStatus.COMPLETED) { + Reservation reservation = findOrCreateCompletedReservation(booking, scheduleSeat.getId(), booking.getCompletedAt()); + ensureSeatSold(scheduleSeat); + ensureTicketExists(reservation, scheduleSeat.getSeatId()); + return; + } + + ensureSeatHoldOrSold(scheduleSeat); + + LocalDateTime now = LocalDateTime.now(clock); + booking.complete(now); + scheduleSeat.sold(); + Reservation reservation = findOrCreateCompletedReservation(booking, scheduleSeat.getId(), now); + ensureTicketExists(reservation, scheduleSeat.getSeatId()); + } + + private ScheduleSeat findScheduleSeatWithLock(Long scheduleSeatId) { + ScheduleSeat scheduleSeat = + entityManager.find(ScheduleSeat.class, scheduleSeatId, LockModeType.PESSIMISTIC_WRITE); + + if (scheduleSeat == null) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND, "예매 좌석 정보를 찾을 수 없습니다."); + } + return scheduleSeat; + } + + private Reservation findOrCreateCompletedReservation( + PrereservationBooking booking, + Long scheduleSeatId, + LocalDateTime completedAt + ) { + LocalDateTime effectiveCompletedAt = completedAt != null ? completedAt : LocalDateTime.now(clock); + + Reservation existing = entityManager + .createQuery( + """ + select r + from Reservation r + where r.memberId = :memberId + and r.scheduleId = :scheduleId + and exists ( + select 1 + from ReservationSeat rs + where rs.reservationId = r.id + and rs.scheduleSeatId = :scheduleSeatId + ) + """, + Reservation.class + ) + .setParameter("memberId", booking.getMemberId()) + .setParameter("scheduleId", booking.getScheduleId()) + .setParameter("scheduleSeatId", scheduleSeatId) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(null); + + if (existing != null) { + if (existing.getCompletedAt() == null) { + existing.complete(effectiveCompletedAt); + } + return existing; + } + + Reservation reservation = Reservation.builder() + .scheduleId(booking.getScheduleId()) + .memberId(booking.getMemberId()) + .expiresAt(booking.getExpiresAt()) + .build(); + reservation.complete(effectiveCompletedAt); + + entityManager.persist(reservation); + entityManager.flush(); + + entityManager.persist( + ReservationSeat.builder() + .reservationId(reservation.getId()) + .scheduleSeatId(scheduleSeatId) + .build() + ); + + return reservation; + } + + private void ensureSeatHoldOrSold(ScheduleSeat scheduleSeat) { + if (scheduleSeat.getStatus() == SeatStatus.SOLD) { + return; + } + if (scheduleSeat.getStatus() != SeatStatus.HOLD) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "좌석이 HOLD 상태가 아닙니다."); + } + } + + private void ensureSeatSold(ScheduleSeat scheduleSeat) { + if (scheduleSeat.getStatus() != SeatStatus.SOLD) { + scheduleSeat.sold(); + } + } + + private void ensureTicketExists(Reservation reservation, Long seatId) { + ticketService.createTicket(reservation.getId(), reservation.getMemberId(), seatId); + } +} diff --git a/src/main/java/com/back/b2st/domain/payment/service/PrereservationPaymentHandler.java b/src/main/java/com/back/b2st/domain/payment/service/PrereservationPaymentHandler.java new file mode 100644 index 000000000..4bd3dc79b --- /dev/null +++ b/src/main/java/com/back/b2st/domain/payment/service/PrereservationPaymentHandler.java @@ -0,0 +1,78 @@ +package com.back.b2st.domain.payment.service; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.error.PaymentErrorCode; +import com.back.b2st.domain.performanceschedule.entity.BookingType; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBooking; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBookingStatus; +import com.back.b2st.domain.prereservation.booking.service.PrereservationBookingService; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.domain.scheduleseat.service.SeatHoldTokenService; +import com.back.b2st.domain.seat.grade.entity.SeatGrade; +import com.back.b2st.domain.seat.grade.repository.SeatGradeRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PrereservationPaymentHandler implements PaymentDomainHandler { + + private final ScheduleSeatRepository scheduleSeatRepository; + private final SeatHoldTokenService seatHoldTokenService; + private final PerformanceScheduleRepository performanceScheduleRepository; + private final SeatGradeRepository seatGradeRepository; + private final PrereservationBookingService prereservationBookingService; + + @Override + public boolean supports(DomainType domainType) { + return domainType == DomainType.PRERESERVATION; + } + + @Override + @Transactional(readOnly = true) + public PaymentTarget loadAndValidate(Long bookingId, Long memberId) { + PrereservationBooking booking = prereservationBookingService.getBookingOrThrow(bookingId); + + if (!booking.getMemberId().equals(memberId)) { + throw new BusinessException(PaymentErrorCode.UNAUTHORIZED_PAYMENT_ACCESS); + } + + if (booking.getStatus() != PrereservationBookingStatus.CREATED) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); + } + + ScheduleSeat scheduleSeat = scheduleSeatRepository.findById(booking.getScheduleSeatId()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleSeat.getScheduleId()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + if (schedule.getBookingType() != BookingType.PRERESERVE) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "신청 예매 결제 대상이 아닙니다."); + } + + if (scheduleSeat.getStatus() != SeatStatus.HOLD) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); + } + + seatHoldTokenService.validateOwnership(scheduleSeat.getScheduleId(), scheduleSeat.getSeatId(), memberId); + + Long performanceId = schedule.getPerformance().getPerformanceId(); + SeatGrade seatGrade = seatGradeRepository.findTopByPerformanceIdAndSeatIdOrderByIdDesc( + performanceId, + scheduleSeat.getSeatId() + ) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE)); + + Long expectedAmount = seatGrade.getPrice().longValue(); + return new PaymentTarget(DomainType.PRERESERVATION, bookingId, expectedAmount); + } +} diff --git a/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentFailureHandler.java b/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentFailureHandler.java deleted file mode 100644 index 403c52ffe..000000000 --- a/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentFailureHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.back.b2st.domain.payment.service; - -import org.springframework.stereotype.Component; - -import com.back.b2st.domain.payment.entity.DomainType; -import com.back.b2st.domain.payment.entity.Payment; -import com.back.b2st.domain.reservation.service.ReservationService; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class ReservationPaymentFailureHandler implements PaymentFailureHandler { - - private final ReservationService reservationService; - - @Override - public boolean supports(DomainType domainType) { - return domainType == DomainType.RESERVATION; - } - - @Override - public void handleFailure(Payment payment) { - reservationService.failReservation(payment.getDomainId()); - } -} - diff --git a/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentFinalizer.java b/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentFinalizer.java index 522dd1eb5..8be498f12 100644 --- a/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentFinalizer.java +++ b/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentFinalizer.java @@ -2,6 +2,7 @@ import java.time.Clock; import java.time.LocalDateTime; +import java.util.List; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -10,8 +11,10 @@ import com.back.b2st.domain.payment.entity.Payment; import com.back.b2st.domain.payment.error.PaymentErrorCode; import com.back.b2st.domain.reservation.entity.Reservation; +import com.back.b2st.domain.reservation.entity.ReservationSeat; import com.back.b2st.domain.reservation.entity.ReservationStatus; import com.back.b2st.domain.reservation.error.ReservationErrorCode; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; import com.back.b2st.domain.scheduleseat.entity.SeatStatus; import com.back.b2st.domain.ticket.service.TicketService; @@ -29,6 +32,7 @@ public class ReservationPaymentFinalizer implements PaymentFinalizer { @PersistenceContext private EntityManager entityManager; + private final ReservationSeatRepository reservationSeatRepository; private final TicketService ticketService; private final Clock clock; @@ -40,7 +44,9 @@ public boolean supports(DomainType domainType) { @Override @Transactional public void finalizePayment(Payment payment) { - Reservation reservation = entityManager.find(Reservation.class, payment.getDomainId(), LockModeType.PESSIMISTIC_WRITE); + Reservation reservation = + entityManager.find(Reservation.class, payment.getDomainId(), LockModeType.PESSIMISTIC_WRITE); + if (reservation == null) { throw new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND); } @@ -53,12 +59,20 @@ public void finalizePayment(Payment payment) { throw new BusinessException(ReservationErrorCode.RESERVATION_ALREADY_CANCELED); } - ScheduleSeat scheduleSeat = findScheduleSeatWithLock(reservation.getScheduleId(), reservation.getSeatId()); + List reservationSeats = reservationSeatRepository.findByReservationId(reservation.getId()); + + if (reservationSeats.isEmpty()) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND); + } + + ReservationSeat rs = reservationSeats.getFirst(); + + ScheduleSeat scheduleSeat = findScheduleSeatWithLock(rs.getScheduleSeatId()); // 멱등: 이미 확정된 예매라면 좌석/티켓이 최종 상태인지 보정하고 종료 if (reservation.getStatus() == ReservationStatus.COMPLETED) { ensureSeatSold(scheduleSeat); - ensureTicketExists(reservation); + ensureTicketExists(reservation, scheduleSeat.getSeatId()); return; } @@ -67,18 +81,12 @@ public void finalizePayment(Payment payment) { LocalDateTime now = LocalDateTime.now(clock); reservation.complete(now); scheduleSeat.sold(); - ensureTicketExists(reservation); + ensureTicketExists(reservation, scheduleSeat.getSeatId()); } - private ScheduleSeat findScheduleSeatWithLock(Long scheduleId, Long seatId) { - ScheduleSeat scheduleSeat = entityManager - .createQuery("SELECT s FROM ScheduleSeat s WHERE s.scheduleId = :scheduleId AND s.seatId = :seatId", ScheduleSeat.class) - .setParameter("scheduleId", scheduleId) - .setParameter("seatId", seatId) - .setLockMode(LockModeType.PESSIMISTIC_WRITE) - .getResultStream() - .findFirst() - .orElse(null); + private ScheduleSeat findScheduleSeatWithLock(Long scheduleSeatId) { + ScheduleSeat scheduleSeat = + entityManager.find(ScheduleSeat.class, scheduleSeatId, LockModeType.PESSIMISTIC_WRITE); if (scheduleSeat == null) { throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND, "예매 좌석 정보를 찾을 수 없습니다."); @@ -101,8 +109,7 @@ private void ensureSeatSold(ScheduleSeat scheduleSeat) { } } - private void ensureTicketExists(Reservation reservation) { - ticketService.createTicket(reservation.getId(), reservation.getMemberId(), reservation.getSeatId()); + private void ensureTicketExists(Reservation reservation, Long seatId) { + ticketService.createTicket(reservation.getId(), reservation.getMemberId(), seatId); } -} - +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentHandler.java b/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentHandler.java index a1b65d79a..c9b073387 100644 --- a/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentHandler.java +++ b/src/main/java/com/back/b2st/domain/payment/service/ReservationPaymentHandler.java @@ -1,5 +1,7 @@ package com.back.b2st.domain.payment.service; +import java.util.List; + import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -8,8 +10,10 @@ import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; import com.back.b2st.domain.reservation.entity.Reservation; +import com.back.b2st.domain.reservation.entity.ReservationSeat; import com.back.b2st.domain.reservation.entity.ReservationStatus; import com.back.b2st.domain.reservation.repository.ReservationRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; import com.back.b2st.domain.scheduleseat.entity.SeatStatus; import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; @@ -26,6 +30,7 @@ public class ReservationPaymentHandler implements PaymentDomainHandler { private final ReservationRepository reservationRepository; private final ScheduleSeatRepository scheduleSeatRepository; + private final ReservationSeatRepository reservationSeatRepository; private final SeatHoldTokenService seatHoldTokenService; private final PerformanceScheduleRepository performanceScheduleRepository; private final SeatGradeRepository seatGradeRepository; @@ -50,23 +55,38 @@ public PaymentTarget loadAndValidate(Long reservationId, Long memberId) { throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); } - Long scheduleId = reservation.getScheduleId(); - Long seatId = reservation.getSeatId(); + List reservationSeats = + reservationSeatRepository.findByReservationId(reservationId); - ScheduleSeat scheduleSeat = scheduleSeatRepository.findByScheduleIdAndSeatId(scheduleId, seatId) - .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + if (reservationSeats.isEmpty()) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); + } + + ReservationSeat rs = reservationSeats.getFirst(); + + ScheduleSeat scheduleSeat = + scheduleSeatRepository.findById(rs.getScheduleSeatId()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); if (scheduleSeat.getStatus() != SeatStatus.HOLD) { throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); } - seatHoldTokenService.validateOwnership(scheduleId, seatId, memberId); + seatHoldTokenService.validateOwnership( + scheduleSeat.getScheduleId(), + scheduleSeat.getSeatId(), + memberId + ); - PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleSeat.getScheduleId()) .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + Long performanceId = schedule.getPerformance().getPerformanceId(); - SeatGrade seatGrade = seatGradeRepository.findTopByPerformanceIdAndSeatIdOrderByIdDesc(performanceId, seatId) + SeatGrade seatGrade = seatGradeRepository.findTopByPerformanceIdAndSeatIdOrderByIdDesc( + performanceId, + scheduleSeat.getSeatId() + ) .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE)); Long expectedAmount = seatGrade.getPrice().longValue(); diff --git a/src/main/java/com/back/b2st/domain/payment/service/TradePaymentHandler.java b/src/main/java/com/back/b2st/domain/payment/service/TradePaymentHandler.java index 6ce6c82cd..cb3789651 100644 --- a/src/main/java/com/back/b2st/domain/payment/service/TradePaymentHandler.java +++ b/src/main/java/com/back/b2st/domain/payment/service/TradePaymentHandler.java @@ -3,13 +3,14 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import com.back.b2st.domain.payment.entity.DomainType; -import com.back.b2st.domain.payment.error.PaymentErrorCode; -import com.back.b2st.domain.ticket.entity.Ticket; -import com.back.b2st.domain.ticket.entity.TicketStatus; -import com.back.b2st.domain.ticket.error.TicketErrorCode; -import com.back.b2st.domain.ticket.service.TicketService; -import com.back.b2st.domain.trade.entity.Trade; + import com.back.b2st.domain.payment.entity.DomainType; + import com.back.b2st.domain.payment.error.PaymentErrorCode; + import com.back.b2st.domain.seat.grade.repository.SeatGradeRepository; + import com.back.b2st.domain.ticket.entity.Ticket; + import com.back.b2st.domain.ticket.entity.TicketStatus; + import com.back.b2st.domain.ticket.error.TicketErrorCode; + import com.back.b2st.domain.ticket.service.TicketService; + import com.back.b2st.domain.trade.entity.Trade; import com.back.b2st.domain.trade.entity.TradeStatus; import com.back.b2st.domain.trade.entity.TradeType; import com.back.b2st.domain.trade.error.TradeErrorCode; @@ -27,7 +28,8 @@ public class TradePaymentHandler implements PaymentDomainHandler { @PersistenceContext private EntityManager entityManager; - private final TicketService ticketService; + private final TicketService ticketService; + private final SeatGradeRepository seatGradeRepository; @Override public boolean supports(DomainType domainType) { @@ -63,13 +65,22 @@ public PaymentTarget loadAndValidate(Long tradeId, Long memberId) { throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "거래 가격이 설정되지 않았습니다."); } - // 6. 티켓 상태 사전 검증 (결제 준비 시점에 검증하여 UX 개선) - Ticket ticket = ticketService.getTicketById(trade.getTicketId()); - validateTicketForPayment(ticket, trade); - - Long expectedAmount = trade.getPrice().longValue(); - return new PaymentTarget(DomainType.TRADE, tradeId, expectedAmount); - } + // 6. 티켓 상태 사전 검증 (결제 준비 시점에 검증하여 UX 개선) + Ticket ticket = ticketService.getTicketById(trade.getTicketId()); + validateTicketForPayment(ticket, trade); + + // 7. 정책 검증: 정가(SeatGrade.price) 이하만 허용 + Integer originalPrice = seatGradeRepository + .findTopByPerformanceIdAndSeatIdOrderByIdDesc(trade.getPerformanceId(), ticket.getSeatId()) + .map(seatGrade -> seatGrade.getPrice()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "좌석 가격 정보가 없습니다.")); + if (trade.getPrice() > originalPrice) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "양도 가격이 정가를 초과합니다."); + } + + Long expectedAmount = trade.getPrice().longValue(); + return new PaymentTarget(DomainType.TRADE, tradeId, expectedAmount); + } private void validateTicketForPayment(Ticket ticket, Trade trade) { // 티켓이 양도 가능한 상태인지 검증 diff --git a/src/main/java/com/back/b2st/domain/performance/controller/AdminPerformanceController.java b/src/main/java/com/back/b2st/domain/performance/controller/AdminPerformanceController.java new file mode 100644 index 000000000..67940b535 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/controller/AdminPerformanceController.java @@ -0,0 +1,126 @@ +package com.back.b2st.domain.performance.controller; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.performance.dto.request.CreatePerformanceReq; +import com.back.b2st.domain.performance.dto.request.CreatePresignedUrlReq; +import com.back.b2st.domain.performance.dto.request.UpsertBookingPolicyReq; +import com.back.b2st.domain.performance.dto.response.PerformanceCursorPageRes; +import com.back.b2st.domain.performance.dto.response.PerformanceDetailRes; +import com.back.b2st.domain.performance.service.PerformanceService; +import com.back.b2st.global.common.BaseResponse; +import com.back.b2st.global.s3.dto.response.PresignedUrlRes; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/performances") +@Tag(name = "AdminPerformanceController", description = "공연 관리 API (관리자 전용)") +@SecurityRequirement(name = "BearerAuth") +public class AdminPerformanceController { + + private final PerformanceService performanceService; + + /** + * 공연 생성 (관리자) + * POST /api/admin/performances + */ + @Operation(summary = "공연 생성", description = "새로운 공연을 생성합니다. 생성 즉시 ACTIVE 상태로 노출되며, 예매 정책은 별도로 설정해야 합니다.") + @PostMapping + public BaseResponse createPerformance( + @Valid @RequestBody CreatePerformanceReq request) { + return BaseResponse.created(performanceService.createPerformance(request)); + } + + /** + * 예매 정책 설정 (관리자) + * PUT /api/admin/performances/{performanceId}/booking-policy + */ + @Operation(summary = "예매 정책 설정", description = "공연의 예매 오픈/마감 시간을 설정합니다. ENDED 상태의 공연은 설정할 수 없습니다.") + @PutMapping("/{performanceId}/booking-policy") + public BaseResponse updateBookingPolicy( + @Parameter(description = "공연 ID", example = "1") @PathVariable Long performanceId, + @Valid @RequestBody UpsertBookingPolicyReq request) { + performanceService.updateBookingPolicy(performanceId, request); + return BaseResponse.success(null); + } + + /** + * 공연 목록 조회 (관리자용: 상태 무관) - Cursor 기반 페이징 + * GET /api/admin/performances?cursor=123&size=20 + */ + @Operation(summary = "공연 목록 조회", description = "모든 공연 목록을 Cursor 기반으로 조회합니다. 상태(ACTIVE/ENDED)와 무관하게 조회됩니다. cursor는 마지막으로 조회한 공연 ID입니다.") + @GetMapping + public BaseResponse getPerformancesForAdmin( + @Parameter(description = "마지막으로 조회한 공연 ID (첫 조회 시 생략)", example = "123") @RequestParam(required = false) Long cursor, + @Parameter(description = "가져올 개수", example = "20") @RequestParam(defaultValue = "20") int size) { + return BaseResponse.success(performanceService.getPerformancesForAdminWithCursor(cursor, size)); + } + + /** + * 공연 검색 (관리자용: 상태 무관) - Cursor 기반 페이징 + * GET /api/admin/performances/search?keyword=뮤지컬&cursor=123&size=20 + * - keyword null/blank면 전체 목록으로 처리(서비스에서 분기) + */ + @Operation(summary = "공연 검색", description = "키워드로 공연을 Cursor 기반으로 검색합니다. 제목 또는 장르에서 검색됩니다. 키워드가 없으면 전체 목록을 반환합니다.") + @GetMapping("/search") + public BaseResponse searchPerformancesForAdmin( + @Parameter(description = "검색 키워드", example = "뮤지컬") @RequestParam(required = false) String keyword, + @Parameter(description = "마지막으로 조회한 공연 ID (첫 조회 시 생략)", example = "123") @RequestParam(required = false) Long cursor, + @Parameter(description = "가져올 개수", example = "20") @RequestParam(defaultValue = "20") int size) { + return BaseResponse.success(performanceService.searchPerformancesForAdminWithCursor(cursor, keyword, size)); + } + + /** + * 공연 상세 조회 (관리자용: 상태 무관) + * GET /api/admin/performances/{performanceId} + */ + @Operation(summary = "공연 상세 조회", description = "공연의 상세 정보를 조회합니다. 상태와 무관하게 조회됩니다.") + @GetMapping("/{performanceId}") + public BaseResponse getPerformanceForAdmin( + @Parameter(description = "공연 ID", example = "1") @PathVariable Long performanceId) { + return BaseResponse.success(performanceService.getPerformanceForAdmin(performanceId)); + } + + /** + * 공연 삭제 (관리자) + * DELETE /api/admin/performances/{performanceId} + */ + @Operation(summary = "공연 삭제", description = "공연을 삭제합니다. 하드 딜리트로 완전히 삭제됩니다.") + @DeleteMapping("/{performanceId}") + public BaseResponse deletePerformance( + @Parameter(description = "공연 ID", example = "1") @PathVariable Long performanceId) { + performanceService.deletePerformance(performanceId); + return BaseResponse.success(null); + } + + /** + * 포스터 이미지 업로드용 Presigned URL 발급 (관리자) + * POST /api/admin/performances/poster/presign + */ + @Operation(summary = "포스터 이미지 업로드용 Presigned URL 발급", description = + "S3에 포스터 이미지를 직접 업로드하기 위한 Presigned URL을 발급합니다. " + + + "허용된 이미지 형식: image/jpeg, image/png, image/webp. 최대 파일 크기: 10MB.") + @PostMapping("/poster/presign") + public BaseResponse generatePresignedUrl( + @Valid @RequestBody CreatePresignedUrlReq request) { + return BaseResponse.success(performanceService.generatePosterPresign( + request.contentType(), + request.fileSize())); + } +} diff --git a/src/main/java/com/back/b2st/domain/performance/controller/PerformanceController.java b/src/main/java/com/back/b2st/domain/performance/controller/PerformanceController.java index 40a2ac22a..85791c196 100644 --- a/src/main/java/com/back/b2st/domain/performance/controller/PerformanceController.java +++ b/src/main/java/com/back/b2st/domain/performance/controller/PerformanceController.java @@ -1,56 +1,78 @@ package com.back.b2st.domain.performance.controller; +import com.back.b2st.domain.performance.dto.response.PerformanceCursorPageRes; import com.back.b2st.domain.performance.dto.response.PerformanceDetailRes; -import com.back.b2st.domain.performance.dto.response.PerformanceListRes; import com.back.b2st.domain.performance.service.PerformanceService; import com.back.b2st.global.common.BaseResponse; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import lombok.RequiredArgsConstructor; - @RestController @RequiredArgsConstructor @RequestMapping("/api/performances") +@Tag(name = "PerformanceController", description = "공연 조회 API") public class PerformanceController { private final PerformanceService performanceService; /** - * 공연 목록 조회 (판매중만) + * 공연 목록 조회 (사용자용: ACTIVE만) - Cursor 기반 페이징 + * GET /api/performances?cursor=123&size=20 */ + @Operation( + summary = "공연 목록 조회", + description = "활성(ACTIVE) 상태인 공연 목록을 Cursor 기반으로 조회합니다. 예매 가능 여부는 isBookable 필드로 확인할 수 있습니다. cursor는 마지막으로 조회한 공연 ID입니다." + ) @GetMapping - public BaseResponse> getPerformances( - @PageableDefault(size = 20) Pageable pageable + public BaseResponse getPerformances( + @Parameter(description = "마지막으로 조회한 공연 ID (첫 조회 시 생략)", example = "123") + @RequestParam(required = false) Long cursor, + @Parameter(description = "가져올 개수", example = "20") + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size ) { - return BaseResponse.success(performanceService.getOnSalePerformances(pageable)); + return BaseResponse.success(performanceService.getActivePerformancesWithCursor(cursor, size)); } /** - * 공연 상세 조회 (판매중만) + * 공연 상세 조회 (사용자용: ACTIVE만) + * GET /api/performances/{performanceId} */ + @Operation(summary = "공연 상세 조회", description = "활성(ACTIVE) 상태인 공연의 상세 정보를 조회합니다.") @GetMapping("/{performanceId}") public BaseResponse getPerformance( - @PathVariable Long performanceId + @Parameter(description = "공연 ID", example = "1") + @PathVariable Long performanceId ) { - return BaseResponse.success(performanceService.getOnSalePerformance(performanceId)); + return BaseResponse.success(performanceService.getActivePerformance(performanceId)); } /** - * 공연 검색 + * 공연 검색 (사용자용: ACTIVE + 키워드) - Cursor 기반 페이징 + * GET /api/performances/search?keyword=뮤지컬&cursor=123&size=20 + * - keyword null/blank면 ACTIVE 목록으로 처리(서비스에서 분기) */ + @Operation( + summary = "공연 검색", + description = "키워드로 활성(ACTIVE) 상태인 공연을 Cursor 기반으로 검색합니다. 제목 또는 장르에서 검색됩니다. 키워드가 없으면 활성 공연 목록을 반환합니다." + ) @GetMapping("/search") - public BaseResponse> searchPerformances( - @RequestParam String keyword, - @PageableDefault(size = 20) Pageable pageable + public BaseResponse searchPerformances( + @Parameter(description = "검색 키워드", example = "뮤지컬") + @RequestParam(required = false) String keyword, + @Parameter(description = "마지막으로 조회한 공연 ID (첫 조회 시 생략)", example = "123") + @RequestParam(required = false) Long cursor, + @Parameter(description = "가져올 개수", example = "20") + @RequestParam(defaultValue = "20") int size ) { - return BaseResponse.success(performanceService.searchOnSalePerformances(keyword, pageable)); + return BaseResponse.success(performanceService.searchActivePerformancesWithCursor(cursor, keyword, size)); } } diff --git a/src/main/java/com/back/b2st/domain/performance/dto/request/CreatePerformanceReq.java b/src/main/java/com/back/b2st/domain/performance/dto/request/CreatePerformanceReq.java new file mode 100644 index 000000000..b6e1a97d6 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/dto/request/CreatePerformanceReq.java @@ -0,0 +1,49 @@ +package com.back.b2st.domain.performance.dto.request; + +import java.time.LocalDateTime; + +import com.back.b2st.domain.performanceschedule.entity.BookingType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 공연 생성 요청 DTO + */ +public record CreatePerformanceReq( + @NotNull + Long venueId, + + @NotBlank + @Size(max = 200) + String title, + + @NotBlank + @Size(max = 50) + String category, + + /** + * 포스터 이미지의 S3 objectKey (DB 저장 값). + * 클라이언트는 presigned URL 업로드 후 반환받은 objectKey를 posterKey로 전달합니다. + * DB에는 posterKey(objectKey)만 저장하고, 응답 DTO에서는 이를 public URL로 변환해 내려줍니다. + */ + @Size(max = 500) + String posterKey, + + @Size(max = 5000) + String description, + + @NotNull + LocalDateTime startDate, + + @NotNull + LocalDateTime endDate, + + BookingType bookingType, // nullable (LOTTERY, FIRST_COME, PRERESERVE) + + LocalDateTime bookingOpenAt, + + LocalDateTime bookingCloseAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/performance/dto/request/CreatePresignedUrlReq.java b/src/main/java/com/back/b2st/domain/performance/dto/request/CreatePresignedUrlReq.java new file mode 100644 index 000000000..6d73a3842 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/dto/request/CreatePresignedUrlReq.java @@ -0,0 +1,18 @@ +package com.back.b2st.domain.performance.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreatePresignedUrlReq( + @NotBlank(message = "Content-Type은 필수입니다.") + String contentType, + + @NotNull(message = "파일 크기는 필수입니다.") + @Min(value = 1, message = "파일 크기는 1바이트 이상이어야 합니다.") + @Max(value = 10485760, message = "파일 크기는 10MB 이하여야 합니다.") + Long fileSize +) { +} + diff --git a/src/main/java/com/back/b2st/domain/performance/dto/request/UpsertBookingPolicyReq.java b/src/main/java/com/back/b2st/domain/performance/dto/request/UpsertBookingPolicyReq.java new file mode 100644 index 000000000..2d4b03a95 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/dto/request/UpsertBookingPolicyReq.java @@ -0,0 +1,17 @@ +package com.back.b2st.domain.performance.dto.request; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotNull; + +/** + * 예매 정책 설정 요청 DTO + */ +public record UpsertBookingPolicyReq( + @NotNull + LocalDateTime bookingOpenAt, + + LocalDateTime bookingCloseAt +) { +} + diff --git a/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceCursorPageRes.java b/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceCursorPageRes.java new file mode 100644 index 000000000..bec564bc1 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceCursorPageRes.java @@ -0,0 +1,26 @@ +package com.back.b2st.domain.performance.dto.response; + +import java.util.List; + +/** + * Cursor 기반 페이징 응답 DTO + */ +public record PerformanceCursorPageRes( + List content, // 공연 목록 + Long nextCursor, // 다음 페이지를 가져올 cursor (null이면 마지막 페이지) + boolean hasNext // 다음 페이지 존재 여부 +) { + public static PerformanceCursorPageRes of(List content, int size) { + boolean hasNext = content.size() > size; + List actualContent = hasNext + ? content.subList(0, size) + : content; + + Long nextCursor = hasNext && !actualContent.isEmpty() + ? actualContent.get(actualContent.size() - 1).performanceId() + : null; + + return new PerformanceCursorPageRes(actualContent, nextCursor, hasNext); + } +} + diff --git a/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceDetailRes.java b/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceDetailRes.java index 5049350c3..ccebd10e4 100644 --- a/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceDetailRes.java +++ b/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceDetailRes.java @@ -3,57 +3,66 @@ import java.time.LocalDateTime; import java.util.List; +import com.back.b2st.domain.performanceschedule.entity.BookingType; // 변경 import com.back.b2st.domain.performance.entity.Performance; import com.back.b2st.domain.performance.entity.PerformanceStatus; import com.back.b2st.domain.seat.grade.entity.SeatGradeType; import com.back.b2st.domain.venue.venue.entity.Venue; +/** + * 공연 상세 응답 DTO + */ public record PerformanceDetailRes( - Long performanceId, //공연ID - String title, //공연제목 - String category, //장르 - String posterUrl, //포스터URL - String description, //공연설명 - LocalDateTime startDate, //공연시작일 - LocalDateTime endDate, //공연종료일 - PerformanceStatus status, //공연상태 - VenueSummary venue, //공연장 정보 - List greadPrices + Long performanceId, + String title, + String category, + String posterUrl, // 최종 URL + String description, + LocalDateTime startDate, + LocalDateTime endDate, + PerformanceStatus status, + LocalDateTime bookingOpenAt, + LocalDateTime bookingCloseAt, + boolean isBookable, + BookingType bookingType, + VenueSummary venue, + List gradePrices ) { - public record GreadPrice( + public record GradePrice( SeatGradeType gradeType, Integer price - ) { - } + ) {} - public static PerformanceDetailRes from(Performance performance) { + /** + * Performance 엔티티를 PerformanceDetailRes로 변환 + * + * @param performance 공연 엔티티 + * @param now 현재 시각 + * @param gradePrices 등급별 가격 정보 + * @param resolvedPosterUrl 변환된 최종 포스터 URL (Mapper에서 제공) + */ + public static PerformanceDetailRes from(Performance performance, LocalDateTime now, List gradePrices, String resolvedPosterUrl) { return new PerformanceDetailRes( performance.getPerformanceId(), performance.getTitle(), performance.getCategory(), - performance.getPosterUrl(), + resolvedPosterUrl, performance.getDescription(), performance.getStartDate(), performance.getEndDate(), performance.getStatus(), + performance.getBookingOpenAt(), + performance.getBookingCloseAt(), + performance.isBookable(now), + performance.getBookingType(), VenueSummary.from(performance.getVenue()), - // todo 등급, 가격 정보 데이터도 받아오도록 - List.of( - new GreadPrice(SeatGradeType.VIP, 30000), - new GreadPrice(SeatGradeType.STANDARD, 10000) - ) + gradePrices == null ? List.of() : List.copyOf(gradePrices) ); } - public record VenueSummary( - Long venueId, - String name - ) { + public record VenueSummary(Long venueId, String name) { public static VenueSummary from(Venue venue) { - return new VenueSummary( - venue.getVenueId(), - venue.getName() - ); + return new VenueSummary(venue.getVenueId(), venue.getName()); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceListRes.java b/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceListRes.java index b5916cefc..716530d73 100644 --- a/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceListRes.java +++ b/src/main/java/com/back/b2st/domain/performance/dto/response/PerformanceListRes.java @@ -1,27 +1,35 @@ package com.back.b2st.domain.performance.dto.response; -import java.time.LocalDateTime; - +import com.back.b2st.domain.performanceschedule.entity.BookingType; import com.back.b2st.domain.performance.entity.Performance; +import java.time.LocalDateTime; public record PerformanceListRes( - Long performanceId, // 공연 ID - String title, // 공연 제목 - String category, // 장르 - String posterUrl, // 포스터 URL - String venueName, // 공연장 이름 - LocalDateTime startDate, // 공연 시작일 - LocalDateTime endDate // 공연 종료일 + Long performanceId, + String title, + String category, + String posterUrl, + String venueName, + LocalDateTime startDate, + LocalDateTime endDate, + LocalDateTime bookingOpenAt, + LocalDateTime bookingCloseAt, + boolean isBookable, + BookingType bookingType ) { - public static PerformanceListRes from(Performance performance) { + public static PerformanceListRes from(Performance performance, LocalDateTime now, String resolvedPosterUrl) { return new PerformanceListRes( - performance.getPerformanceId(), - performance.getTitle(), - performance.getCategory(), - performance.getPosterUrl(), - performance.getVenue().getName(), - performance.getStartDate(), - performance.getEndDate() + performance.getPerformanceId(), + performance.getTitle(), + performance.getCategory(), + resolvedPosterUrl, + performance.getVenue().getName(), + performance.getStartDate(), + performance.getEndDate(), + performance.getBookingOpenAt(), + performance.getBookingCloseAt(), + performance.isBookable(now), + performance.getBookingType() ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/performance/entity/BookingType.java b/src/main/java/com/back/b2st/domain/performance/entity/BookingType.java new file mode 100644 index 000000000..9bea01197 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/entity/BookingType.java @@ -0,0 +1,7 @@ +package com.back.b2st.domain.performance.entity; + +public enum BookingType { + LOTTERY, // 추첨 + GENERAL, // 일반예매 + PRE_REGISTRATION // 구역별 사전등록 +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/performance/entity/Performance.java b/src/main/java/com/back/b2st/domain/performance/entity/Performance.java index 8791e6c2b..5a96cc041 100644 --- a/src/main/java/com/back/b2st/domain/performance/entity/Performance.java +++ b/src/main/java/com/back/b2st/domain/performance/entity/Performance.java @@ -1,8 +1,12 @@ package com.back.b2st.domain.performance.entity; import java.time.LocalDateTime; +import java.util.Objects; +import com.back.b2st.domain.performance.error.PerformanceErrorCode; +import com.back.b2st.domain.performanceschedule.entity.BookingType; // 변경: 공연 회차의 BookingType 사용 import com.back.b2st.domain.venue.venue.entity.Venue; +import com.back.b2st.global.error.exception.BusinessException; import com.back.b2st.global.jpa.entity.BaseEntity; import jakarta.persistence.Column; @@ -16,6 +20,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.SequenceGenerator; import jakarta.persistence.Table; import lombok.AccessLevel; @@ -37,63 +43,143 @@ public class Performance extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "performance_id_gen") @Column(name = "performance_id") - private Long performanceId; // PK + private Long performanceId; // PK @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "venue_id", nullable = false) - private Venue venue; //공연장 FK + private Venue venue; // 공연장 FK @Column(nullable = false, length = 200) - private String title; //공연제목 + private String title; // 공연제목 @Column(nullable = false, length = 50) - private String category; //장르 + private String category; // 장르 - @Column(name = "poster_url", length = 500) - private String posterUrl; //포스터 이미지 URL + @Column(name = "poster_key", length = 500) + private String posterKey; // 포스터 이미지 S3 Object Key @Lob - private String description; //공연 설명 + private String description; // 공연 설명 @Column(name = "start_date", nullable = false) - private LocalDateTime startDate; //공연 시작일 + private LocalDateTime startDate; // 공연 시작일 @Column(name = "end_date", nullable = false) - private LocalDateTime endDate; //공연 종료일 + private LocalDateTime endDate; // 공연 종료일 @Enumerated(EnumType.STRING) @Column(nullable = false) private PerformanceStatus status; + @Enumerated(EnumType.STRING) + @Column(name = "booking_type") + private BookingType bookingType; // 예매 유형 (LOTTERY/FIRST_COME/PRERESERVE) + + @Column(name = "booking_open_at") + private LocalDateTime bookingOpenAt; // 예매 오픈 시각 (null이면 예매 불가) + + @Column(name = "booking_close_at") + private LocalDateTime bookingCloseAt; // 예매 마감 시각 + @Builder public Performance( Venue venue, String title, String category, - String posterUrl, + String posterKey, String description, LocalDateTime startDate, LocalDateTime endDate, - PerformanceStatus status + PerformanceStatus status, + BookingType bookingType, + LocalDateTime bookingOpenAt, + LocalDateTime bookingCloseAt ) { - this.venue = venue; - this.title = title; - this.category = category; - this.posterUrl = posterUrl; + this.venue = Objects.requireNonNull(venue, "venue must not be null"); + this.title = Objects.requireNonNull(title, "title must not be null"); + this.category = Objects.requireNonNull(category, "category must not be null"); + this.posterKey = posterKey; this.description = description; - this.startDate = startDate; - this.endDate = endDate; - this.status = status; + this.startDate = Objects.requireNonNull(startDate, "startDate must not be null"); + this.endDate = Objects.requireNonNull(endDate, "endDate must not be null"); + this.status = Objects.requireNonNull(status, "status must not be null"); + this.bookingType = bookingType; + this.bookingOpenAt = bookingOpenAt; + this.bookingCloseAt = bookingCloseAt; + + validatePeriod(this.startDate, this.endDate); + validateBookingPolicy(this.bookingOpenAt, this.bookingCloseAt); } - /** 공연 상태 변경 */ + /** + * 공연 상태 변경 + */ public void updateStatus(PerformanceStatus status) { - this.status = status; + this.status = Objects.requireNonNull(status, "status must not be null"); } - /** 공연이 판매 중인지 여부 */ + /** + * 활성(노출) 여부. + * 주의: "예매 가능"은 isBookable(now)로 판단한다. + */ public boolean isOnSale() { - return this.status == PerformanceStatus.ON_SALE; + return this.status == PerformanceStatus.ACTIVE; } -} + /** + * 예매 가능 여부 계산 + * @param now 현재 시각 + * @return 예매 가능 여부 + */ + public boolean isBookable(LocalDateTime now) { + Objects.requireNonNull(now, "now must not be null"); + + if (status == PerformanceStatus.ENDED) return false; + if (bookingOpenAt == null) return false; + if (now.isBefore(bookingOpenAt)) return false; + if (bookingCloseAt != null && !now.isBefore(bookingCloseAt)) return false; // now >= closeAt + return true; + } + + /** + * 예매 정책 + * - bookingOpenAt은 null 허용(미설정 상태를 표현). 단, closeAt 단독 설정은 불가. + * - closeAt이 있으면 openAt < closeAt 이어야 함. + */ + public void updateBookingPolicy(LocalDateTime bookingOpenAt, LocalDateTime bookingCloseAt) { + validateBookingPolicy(bookingOpenAt, bookingCloseAt); + this.bookingOpenAt = bookingOpenAt; + this.bookingCloseAt = bookingCloseAt; + } + + @PrePersist + @PreUpdate + private void validateEntityState() { + // JPA flush 시점에서도 최소 무결성 방어 + validatePeriod(this.startDate, this.endDate); + validateBookingPolicy(this.bookingOpenAt, this.bookingCloseAt); + if (this.status == null) { + throw new BusinessException(PerformanceErrorCode.PERFORMANCE_INTERNAL_ERROR, "공연 상태는 필수입니다."); + } + if (this.venue == null) { + throw new BusinessException(PerformanceErrorCode.PERFORMANCE_INTERNAL_ERROR, "공연장은 필수입니다."); + } + } + + private static void validatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (!startDate.isBefore(endDate)) { + throw new BusinessException(PerformanceErrorCode.INVALID_PERFORMANCE_PERIOD); + } + } + + private static void validateBookingPolicy(LocalDateTime bookingOpenAt, LocalDateTime bookingCloseAt) { + // closeAt만 단독 설정 금지 + if (bookingOpenAt == null && bookingCloseAt != null) { + throw new BusinessException(PerformanceErrorCode.INVALID_BOOKING_POLICY); + } + // 둘 다 있으면 open < close + if (bookingOpenAt != null && bookingCloseAt != null && !bookingOpenAt.isBefore(bookingCloseAt)) { + throw new BusinessException(PerformanceErrorCode.INVALID_BOOKING_TIME); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/performance/entity/PerformanceStatus.java b/src/main/java/com/back/b2st/domain/performance/entity/PerformanceStatus.java index 0e027e7f4..e85279846 100644 --- a/src/main/java/com/back/b2st/domain/performance/entity/PerformanceStatus.java +++ b/src/main/java/com/back/b2st/domain/performance/entity/PerformanceStatus.java @@ -1,6 +1,6 @@ package com.back.b2st.domain.performance.entity; public enum PerformanceStatus { - ON_SALE, // 판매중 - ENDED //판매종료 + ACTIVE, // 활성(노출 가능). 예매 가능 여부는 bookingOpenAt 기준 + ENDED // 종료(노출/예매 불가) } diff --git a/src/main/java/com/back/b2st/domain/performance/error/PerformanceErrorCode.java b/src/main/java/com/back/b2st/domain/performance/error/PerformanceErrorCode.java new file mode 100644 index 000000000..4f6f32699 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/error/PerformanceErrorCode.java @@ -0,0 +1,45 @@ +package com.back.b2st.domain.performance.error; + +import org.springframework.http.HttpStatus; + +import com.back.b2st.global.error.code.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 공연 시스템 에러 코드 + */ +@Getter +@RequiredArgsConstructor +public enum PerformanceErrorCode implements ErrorCode { + + // 공연 관련 + PERFORMANCE_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "공연을 찾을 수 없습니다."), + PERFORMANCE_ALREADY_EXISTS(HttpStatus.CONFLICT, "P002", "이미 존재하는 공연입니다."), + PERFORMANCE_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "P003", "활성 상태가 아닌 공연입니다."), + + // 공연장 관련 + VENUE_NOT_FOUND(HttpStatus.NOT_FOUND, "P101", "공연장을 찾을 수 없습니다."), + + // 공연 기간 관련 + INVALID_PERFORMANCE_PERIOD(HttpStatus.BAD_REQUEST, "P201", "공연 시작일은 종료일보다 이전이어야 합니다."), + PERFORMANCE_ALREADY_ENDED(HttpStatus.BAD_REQUEST, "P202", "이미 종료된 공연입니다."), + + // 예매 정책 관련 + INVALID_BOOKING_POLICY(HttpStatus.BAD_REQUEST, "P301", "예매 정책이 유효하지 않습니다."), + INVALID_BOOKING_TIME(HttpStatus.BAD_REQUEST, "P302", "예매 오픈/마감 시간이 유효하지 않습니다."), + BOOKING_NOT_OPEN(HttpStatus.BAD_REQUEST, "P303", "예매 오픈 전입니다."), + BOOKING_CLOSED(HttpStatus.BAD_REQUEST, "P304", "예매가 마감되었습니다."), + BOOKING_POLICY_NOT_SET(HttpStatus.BAD_REQUEST, "P305", "예매 정책이 설정되지 않았습니다."), + + // 삭제 관련 + PERFORMANCE_DELETE_NOT_ALLOWED(HttpStatus.CONFLICT, "P401", "공연을 삭제할 수 없습니다."), + + // 내부 오류 + PERFORMANCE_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P999", "공연 시스템 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/back/b2st/domain/performance/mapper/PerformanceMapper.java b/src/main/java/com/back/b2st/domain/performance/mapper/PerformanceMapper.java new file mode 100644 index 000000000..8b84ebf0e --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/mapper/PerformanceMapper.java @@ -0,0 +1,54 @@ +package com.back.b2st.domain.performance.mapper; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.performance.dto.response.PerformanceDetailRes; +import com.back.b2st.domain.performance.dto.response.PerformanceListRes; +import com.back.b2st.domain.performance.entity.Performance; +import com.back.b2st.global.s3.service.S3Service; + +import lombok.RequiredArgsConstructor; + +/** + * Performance 엔티티를 DTO로 변환하는 Mapper + * + * - S3는 Private 유지 + * - posterKey를 Presigned GET URL로 변환하여 응답에 포함 + */ +@Component +@RequiredArgsConstructor +public class PerformanceMapper { + + private final S3Service s3Service; + + public PerformanceListRes toListRes(Performance performance, LocalDateTime now) { + String posterUrl = resolvePosterUrl(performance.getPosterKey()); + return PerformanceListRes.from(performance, now, posterUrl); + } + + public PerformanceDetailRes toDetailRes( + Performance performance, + LocalDateTime now, + List gradePrices + ) { + String posterUrl = resolvePosterUrl(performance.getPosterKey()); + return PerformanceDetailRes.from(performance, now, gradePrices, posterUrl); + } + + /** + * posterKey를 Presigned GET URL로 변환 + * + * - S3가 Private이므로 일반 URL 조립은 불가 + * - 필요 시점마다 Presigned GET URL을 발급한다. + */ + private String resolvePosterUrl(String posterKey) { + if (posterKey == null || posterKey.isBlank()) { + return null; + } + + return s3Service.generatePresignedDownloadUrl(posterKey.trim()); + } +} diff --git a/src/main/java/com/back/b2st/domain/performance/metrics/PerformanceMetrics.java b/src/main/java/com/back/b2st/domain/performance/metrics/PerformanceMetrics.java new file mode 100644 index 000000000..da3919261 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/performance/metrics/PerformanceMetrics.java @@ -0,0 +1,40 @@ +package com.back.b2st.domain.performance.metrics; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class PerformanceMetrics { + private final MeterRegistry registry; + + public PerformanceMetrics(MeterRegistry registry) { + this.registry = registry; + } + + /** 공연 상세 페이지 조회 기록 */ + public void recordView(Long performanceId) { + Counter.builder("performance_view_total") + .tag("performance_id", String.valueOf(performanceId)) + .register(registry) + .increment(); + } + + /** 공연 회차 조회 기록 */ + public void recordScheduleView(Long scheduleId) { + Counter.builder("schedule_view_total") + .tag("schedule_id", String.valueOf(scheduleId)) + .register(registry) + .increment(); + } + + /** 좌석 선택 기록 */ + public void recordSeatSelection(Long scheduleId, String seatGrade) { + Counter.builder("seat_selection_total") + .tag("schedule_id", String.valueOf(scheduleId)) + .tag("grade", seatGrade) + .register(registry) + .increment(); + } +} diff --git a/src/main/java/com/back/b2st/domain/performance/repository/PerformanceRepository.java b/src/main/java/com/back/b2st/domain/performance/repository/PerformanceRepository.java index 9bd749d32..27fe90223 100644 --- a/src/main/java/com/back/b2st/domain/performance/repository/PerformanceRepository.java +++ b/src/main/java/com/back/b2st/domain/performance/repository/PerformanceRepository.java @@ -1,8 +1,7 @@ package com.back.b2st.domain.performance.repository; -import com.back.b2st.domain.performance.entity.Performance; -import com.back.b2st.domain.performance.entity.PerformanceStatus; - +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -12,42 +11,129 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.back.b2st.domain.performance.entity.Performance; +import com.back.b2st.domain.performance.entity.PerformanceStatus; public interface PerformanceRepository extends JpaRepository { - /** - * 판매중인 공연만 목록 조회(사용자용) - */ + /* ========================= + * 사용자용 (ACTIVE만) - Offset(Page) + * ========================= */ + @EntityGraph(attributePaths = "venue") Page findByStatus(PerformanceStatus status, Pageable pageable); - /** - * 공연 상세 조회(판매중만) - */ @EntityGraph(attributePaths = "venue") - Optional findWithVenueByPerformanceIdAndStatus( - Long performanceId, - PerformanceStatus status + Optional findWithVenueByPerformanceIdAndStatus(Long performanceId, PerformanceStatus status); + + @EntityGraph(attributePaths = "venue") + @Query(""" + select p + from Performance p + where p.status = :status + and ( + lower(p.title) like lower(concat('%', :keyword, '%')) + or lower(p.category) like lower(concat('%', :keyword, '%')) + ) + """) + Page searchActive( + @Param("status") PerformanceStatus status, + @Param("keyword") String keyword, + Pageable pageable + ); + + /* ========================= + * 관리자용 (상태 무관) - Offset(Page) + * ========================= */ + + @Override + @EntityGraph(attributePaths = "venue") + Page findAll(Pageable pageable); + + @EntityGraph(attributePaths = "venue") + Optional findWithVenueByPerformanceId(Long performanceId); + + @EntityGraph(attributePaths = "venue") + @Query(""" + select p + from Performance p + where lower(p.title) like lower(concat('%', :keyword, '%')) + or lower(p.category) like lower(concat('%', :keyword, '%')) + """) + Page searchAll(@Param("keyword") String keyword, Pageable pageable); + + /* ========================= + * Cursor 기반 페이징 (공통 규칙) + * - 정렬: performanceId DESC (최신순) + * - 조건: performanceId < cursor + * - Pageable: PageRequest.of(0, size+1) + * ========================= */ + + /* 사용자용 (ACTIVE만) - Cursor */ + @EntityGraph(attributePaths = "venue") + @Query(""" + select p + from Performance p + where p.status = :status + and p.endDate >= :todayStart + and (:cursor is null or p.performanceId < :cursor) + order by p.performanceId desc + """) + List findByStatusWithCursor( + @Param("status") PerformanceStatus status, + @Param("todayStart") LocalDateTime todayStart, + @Param("cursor") Long cursor, + Pageable pageable ); - /** - * 공연 검색 - */ - @EntityGraph(attributePaths = "venue") - @Query( - """ - select p - from Performance p - where p.status = :status - and ( - lower(p.title) like lower(concat('%', :keyword, '%')) - or lower(p.category) like lower(concat('%', :keyword, '%')) - ) - """ - ) - Page searchOnSale( - @Param("status") PerformanceStatus status, - @Param("keyword") String keyword, - Pageable pageable + @EntityGraph(attributePaths = "venue") + @Query(""" + select p + from Performance p + where p.status = :status + and p.endDate >= :todayStart + and (:cursor is null or p.performanceId < :cursor) + and ( + lower(p.title) like lower(concat('%', :keyword, '%')) + or lower(p.category) like lower(concat('%', :keyword, '%')) + ) + order by p.performanceId desc + """) + List searchActiveWithCursor( + @Param("status") PerformanceStatus status, + @Param("todayStart") LocalDateTime todayStart, + @Param("keyword") String keyword, + @Param("cursor") Long cursor, + Pageable pageable + ); + + /* 관리자용 (상태 무관) - Cursor */ + @EntityGraph(attributePaths = "venue") + @Query(""" + select p + from Performance p + where (:cursor is null or p.performanceId < :cursor) + order by p.performanceId desc + """) + List findAllWithCursor( + @Param("cursor") Long cursor, + Pageable pageable + ); + + @EntityGraph(attributePaths = "venue") + @Query(""" + select p + from Performance p + where (:cursor is null or p.performanceId < :cursor) + and ( + lower(p.title) like lower(concat('%', :keyword, '%')) + or lower(p.category) like lower(concat('%', :keyword, '%')) + ) + order by p.performanceId desc + """) + List searchAllWithCursor( + @Param("keyword") String keyword, + @Param("cursor") Long cursor, + Pageable pageable ); } diff --git a/src/main/java/com/back/b2st/domain/performance/service/PerformanceService.java b/src/main/java/com/back/b2st/domain/performance/service/PerformanceService.java index 0b3f406e5..1de353ff1 100644 --- a/src/main/java/com/back/b2st/domain/performance/service/PerformanceService.java +++ b/src/main/java/com/back/b2st/domain/performance/service/PerformanceService.java @@ -1,17 +1,51 @@ package com.back.b2st.domain.performance.service; -import com.back.b2st.domain.performance.dto.response.PerformanceDetailRes; -import com.back.b2st.domain.performance.dto.response.PerformanceListRes; -import com.back.b2st.domain.performance.entity.PerformanceStatus; -import com.back.b2st.domain.performance.repository.PerformanceRepository; -import com.back.b2st.global.error.code.CommonErrorCode; -import com.back.b2st.global.error.exception.BusinessException; +import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.back.b2st.domain.performance.dto.request.CreatePerformanceReq; +import com.back.b2st.domain.performance.dto.request.UpsertBookingPolicyReq; +import com.back.b2st.domain.performance.dto.response.PerformanceCursorPageRes; +import com.back.b2st.domain.performance.dto.response.PerformanceDetailRes; +import com.back.b2st.domain.performance.dto.response.PerformanceListRes; +import com.back.b2st.domain.performance.entity.Performance; +import com.back.b2st.domain.performance.entity.PerformanceStatus; +import com.back.b2st.domain.performance.error.PerformanceErrorCode; +import com.back.b2st.domain.performance.mapper.PerformanceMapper; +import com.back.b2st.domain.performance.repository.PerformanceRepository; +import com.back.b2st.domain.lottery.entry.repository.LotteryEntryRepository; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.repository.PaymentRepository; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.booking.repository.PrereservationBookingRepository; +import com.back.b2st.domain.prereservation.entry.repository.PrereservationRepository; +import com.back.b2st.domain.prereservation.policy.repository.PrereservationTimeTableRepository; +import com.back.b2st.domain.queue.repository.QueueRepository; +import com.back.b2st.domain.reservation.repository.ReservationRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.domain.seat.grade.entity.SeatGrade; +import com.back.b2st.domain.seat.grade.entity.SeatGradeType; +import com.back.b2st.domain.seat.grade.repository.SeatGradeRepository; +import com.back.b2st.domain.seat.seat.entity.Seat; +import com.back.b2st.domain.seat.seat.error.SeatErrorCode; +import com.back.b2st.domain.seat.seat.repository.SeatRepository; +import com.back.b2st.domain.ticket.repository.TicketRepository; +import com.back.b2st.domain.trade.repository.TradeRepository; +import com.back.b2st.domain.trade.repository.TradeRequestRepository; +import com.back.b2st.domain.venue.venue.entity.Venue; +import com.back.b2st.domain.venue.venue.repository.VenueRepository; +import com.back.b2st.global.error.exception.BusinessException; +import com.back.b2st.global.s3.dto.response.PresignedUrlRes; +import com.back.b2st.global.s3.service.S3Service; + import lombok.RequiredArgsConstructor; @Service @@ -20,40 +54,391 @@ public class PerformanceService { private final PerformanceRepository performanceRepository; + private final VenueRepository venueRepository; + private final PerformanceScheduleRepository performanceScheduleRepository; + private final SeatRepository seatRepository; + private final SeatGradeRepository seatGradeRepository; + private final ScheduleSeatRepository scheduleSeatRepository; + private final PrereservationBookingRepository prereservationBookingRepository; + private final PrereservationRepository prereservationRepository; + private final PrereservationTimeTableRepository prereservationTimeTableRepository; + private final ReservationRepository reservationRepository; + private final ReservationSeatRepository reservationSeatRepository; + private final TicketRepository ticketRepository; + private final LotteryEntryRepository lotteryEntryRepository; + private final LotteryResultRepository lotteryResultRepository; + private final TradeRepository tradeRepository; + private final TradeRequestRepository tradeRequestRepository; + private final QueueRepository queueRepository; + private final PaymentRepository paymentRepository; + + private final PerformanceMapper performanceMapper; + private final S3Service s3Service; + + private static final String POSTER_PREFIX = "performances/posters"; + + /* + * ========================= + * 관리자 기능 + * ========================= + */ /** - * 공연 목록 조회 (판매중인 공연만) + * 포스터 이미지 업로드용 Presigned PUT URL 발급 (관리자) + * + * - prefix는 도메인에서 결정 + * - 반환되는 objectKey를 DB에 저장하여 추후 조회 시 Presigned GET으로 변환 */ - public Page getOnSalePerformances(Pageable pageable) { - return performanceRepository - .findByStatus(PerformanceStatus.ON_SALE, pageable) - .map(PerformanceListRes::from); + public PresignedUrlRes generatePosterPresign(String contentType, long fileSize) { + return s3Service.generatePresignedUploadUrl(POSTER_PREFIX, contentType, fileSize); } - /** - * 공연 상세 조회 (판매중인 공연만) + @Transactional + public PerformanceDetailRes createPerformance(CreatePerformanceReq request) { + if (!request.startDate().isBefore(request.endDate())) { + throw new BusinessException(PerformanceErrorCode.INVALID_PERFORMANCE_PERIOD); + } + + Venue venue = venueRepository.findById(request.venueId()) + .orElseThrow(() -> new BusinessException(PerformanceErrorCode.VENUE_NOT_FOUND)); + + // DB에는 posterKey(objectKey)만 저장 (S3 Private) + String posterKey = normalizePosterKey(request.posterKey()); + + Performance performance = Performance.builder() + .venue(venue) + .title(request.title()) + .category(request.category()) + .posterKey(posterKey) + .description(blankToNull(request.description())) + .startDate(request.startDate()) + .endDate(request.endDate()) + .status(PerformanceStatus.ACTIVE) + .bookingType(request.bookingType()) + .bookingOpenAt(null) + .bookingCloseAt(null) + .build(); + + Performance saved = performanceRepository.save(performance); + createDefaultSeatGrades(saved); + + return performanceMapper.toDetailRes(saved, LocalDateTime.now(), null); + } + + private void createDefaultSeatGrades(Performance performance) { + Long performanceId = performance.getPerformanceId(); + Long venueId = performance.getVenue().getVenueId(); + + List seats = seatRepository.findByVenueId(venueId); + if (seats.isEmpty()) { + throw new BusinessException(SeatErrorCode.SEAT_NOT_FOUND); + } + + List toCreate = seats.stream() + .filter(seat -> !seatGradeRepository.existsByPerformanceIdAndSeatId(performanceId, seat.getId())) + .map(seat -> { + SeatGradeType grade = defaultGrade(seat.getSectionName()); + int price = defaultPrice(grade); + + return SeatGrade.builder() + .performanceId(performanceId) + .seatId(seat.getId()) + .grade(grade) + .price(price) + .build(); + }) + .toList(); + + if (!toCreate.isEmpty()) { + seatGradeRepository.saveAll(toCreate); + } + } + + private SeatGradeType defaultGrade(String sectionName) { + if ("A".equalsIgnoreCase(sectionName)) + return SeatGradeType.VIP; + if ("B".equalsIgnoreCase(sectionName)) + return SeatGradeType.ROYAL; + return SeatGradeType.STANDARD; + } + + private int defaultPrice(SeatGradeType grade) { + return switch (grade) { + case VIP -> 30000; + case ROYAL -> 20000; + default -> 10000; + }; + } + + @Transactional + public void updateBookingPolicy(Long performanceId, UpsertBookingPolicyReq request) { + Performance performance = performanceRepository.findById(performanceId) + .orElseThrow(() -> new BusinessException(PerformanceErrorCode.PERFORMANCE_NOT_FOUND)); + + if (performance.getStatus() == PerformanceStatus.ENDED) { + throw new BusinessException(PerformanceErrorCode.PERFORMANCE_ALREADY_ENDED); + } + + LocalDateTime openAt = request.bookingOpenAt(); + LocalDateTime closeAt = request.bookingCloseAt(); + LocalDateTime endDate = performance.getEndDate(); + + if (closeAt != null && !openAt.isBefore(closeAt)) { + throw new BusinessException(PerformanceErrorCode.INVALID_BOOKING_TIME); + } + + if (openAt.isAfter(endDate)) { + throw new BusinessException(PerformanceErrorCode.INVALID_BOOKING_TIME); + } + + if (closeAt != null && closeAt.isAfter(endDate)) { + throw new BusinessException(PerformanceErrorCode.INVALID_BOOKING_TIME); + } + + performance.updateBookingPolicy(openAt, closeAt); + } + + // 관리자: Offset 목록 + public Page getPerformancesForAdmin(Pageable pageable) { + LocalDateTime now = LocalDateTime.now(); + return performanceRepository.findAll(pageable) + .map(p -> performanceMapper.toListRes(p, now)); + } + + // 관리자: Offset 검색 + public Page searchPerformancesForAdmin(String keyword, Pageable pageable) { + LocalDateTime now = LocalDateTime.now(); + if (keyword == null || keyword.trim().isEmpty()) { + return performanceRepository.findAll(pageable) + .map(p -> performanceMapper.toListRes(p, now)); + } + return performanceRepository.searchAll(keyword.trim(), pageable) + .map(p -> performanceMapper.toListRes(p, now)); + } + + // 관리자: 상세 + public PerformanceDetailRes getPerformanceForAdmin(Long performanceId) { + return performanceRepository.findWithVenueByPerformanceId(performanceId) + .map(p -> performanceMapper.toDetailRes(p, LocalDateTime.now(), null)) + .orElseThrow(() -> new BusinessException(PerformanceErrorCode.PERFORMANCE_NOT_FOUND)); + } + + // 관리자: 삭제 + @Transactional + public void deletePerformance(Long performanceId) { + if (!performanceRepository.existsById(performanceId)) { + throw new BusinessException(PerformanceErrorCode.PERFORMANCE_NOT_FOUND); + } + + List scheduleIds = performanceScheduleRepository.findIdsByPerformanceId(performanceId); + + List reservationIds = scheduleIds.isEmpty() + ? List.of() + : reservationRepository.findIdsByScheduleIdIn(scheduleIds); + List prereservationBookingIds = scheduleIds.isEmpty() + ? List.of() + : prereservationBookingRepository.findIdsByScheduleIdIn(scheduleIds); + List lotteryEntryIds = scheduleIds.isEmpty() + ? List.of() + : lotteryEntryRepository.findIdsByScheduleIdIn(scheduleIds); + List lotteryResultIds = lotteryEntryIds.isEmpty() + ? List.of() + : lotteryResultRepository.findIdsByLotteryEntryIdIn(lotteryEntryIds); + + List tradeIds = tradeRepository.findIdsByPerformanceId(performanceId); + + if (!reservationIds.isEmpty()) { + paymentRepository.deleteAllByDomainTypeAndDomainIdIn(DomainType.RESERVATION, reservationIds); + } + if (!prereservationBookingIds.isEmpty()) { + paymentRepository.deleteAllByDomainTypeAndDomainIdIn(DomainType.PRERESERVATION, prereservationBookingIds); + } + if (!lotteryResultIds.isEmpty()) { + paymentRepository.deleteAllByDomainTypeAndDomainIdIn(DomainType.LOTTERY, lotteryResultIds); + } + if (!tradeIds.isEmpty()) { + paymentRepository.deleteAllByDomainTypeAndDomainIdIn(DomainType.TRADE, tradeIds); + } + + if (!tradeIds.isEmpty()) { + tradeRequestRepository.deleteAllByTrade_IdIn(tradeIds); + tradeRepository.deleteAllByPerformanceId(performanceId); + } + + queueRepository.deleteByPerformanceId(performanceId); + + if (!scheduleIds.isEmpty()) { + if (!lotteryEntryIds.isEmpty()) { + lotteryResultRepository.deleteAllByLotteryEntryIdIn(lotteryEntryIds); + } + lotteryEntryRepository.deleteAllByScheduleIdIn(scheduleIds); + + prereservationRepository.deleteAllByPerformanceScheduleIdIn(scheduleIds); + prereservationTimeTableRepository.deleteAllByPerformanceScheduleIdIn(scheduleIds); + prereservationBookingRepository.deleteAllByScheduleIdIn(scheduleIds); + + if (!reservationIds.isEmpty()) { + reservationSeatRepository.deleteAllByReservationIdIn(reservationIds); + ticketRepository.deleteAllByReservationIdIn(reservationIds); + } + reservationRepository.deleteAllByScheduleIdIn(scheduleIds); + + scheduleSeatRepository.deleteAllByScheduleIdIn(scheduleIds); + performanceScheduleRepository.deleteAllByPerformanceId(performanceId); + } + + seatGradeRepository.deleteAllByPerformanceId(performanceId); + performanceRepository.deleteById(performanceId); + } + + /* + * ========================= + * 사용자 기능 + * ========================= */ - public PerformanceDetailRes getOnSalePerformance(Long performanceId) { - return performanceRepository - .findWithVenueByPerformanceIdAndStatus( - performanceId, - PerformanceStatus.ON_SALE - ) - .map(PerformanceDetailRes::from) - .orElseThrow(() -> - new BusinessException(CommonErrorCode.NOT_FOUND) - ); + + // 사용자: Offset 목록 + public Page getActivePerformances(Pageable pageable) { + LocalDateTime now = LocalDateTime.now(); + return performanceRepository.findByStatus(PerformanceStatus.ACTIVE, pageable) + .map(p -> performanceMapper.toListRes(p, now)); + } + + // 사용자: 상세 + public PerformanceDetailRes getActivePerformance(Long performanceId) { + return performanceRepository.findWithVenueByPerformanceIdAndStatus(performanceId, PerformanceStatus.ACTIVE) + .map(p -> performanceMapper.toDetailRes(p, LocalDateTime.now(), null)) + .orElseThrow(() -> new BusinessException(PerformanceErrorCode.PERFORMANCE_NOT_FOUND)); + } + + // 사용자: Offset 검색 + public Page searchActivePerformances(String keyword, Pageable pageable) { + LocalDateTime now = LocalDateTime.now(); + if (keyword == null || keyword.trim().isEmpty()) { + return performanceRepository.findByStatus(PerformanceStatus.ACTIVE, pageable) + .map(p -> performanceMapper.toListRes(p, now)); + } + return performanceRepository.searchActive(PerformanceStatus.ACTIVE, keyword.trim(), pageable) + .map(p -> performanceMapper.toListRes(p, now)); + } + + /* + * ========================= + * Cursor 기반 페이징 (사용자 기능) + * ========================= + */ + + public PerformanceCursorPageRes getActivePerformancesWithCursor(Long cursor, int size) { + Pageable pageable = PageRequest.of(0, size + 1); + LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay(); + + List performances = performanceRepository + .findByStatusWithCursor(PerformanceStatus.ACTIVE, todayStart, cursor, pageable); + + return mapToCursorRes(performances, size); + } + + public PerformanceCursorPageRes searchActivePerformancesWithCursor(Long cursor, String keyword, int size) { + if (keyword == null || keyword.trim().isEmpty()) { + return getActivePerformancesWithCursor(cursor, size); + } + + Pageable pageable = PageRequest.of(0, size + 1); + LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay(); + + List performances = performanceRepository + .searchActiveWithCursor(PerformanceStatus.ACTIVE, todayStart, keyword.trim(), cursor, pageable); + + return mapToCursorRes(performances, size); + } + + /* + * ========================= + * Cursor 기반 페이징 (관리자 기능) + * ========================= + */ + + public PerformanceCursorPageRes getPerformancesForAdminWithCursor(Long cursor, int size) { + Pageable pageable = PageRequest.of(0, size + 1); + + List performances = performanceRepository + .findAllWithCursor(cursor, pageable); + + return mapToCursorRes(performances, size); + } + + public PerformanceCursorPageRes searchPerformancesForAdminWithCursor(Long cursor, String keyword, int size) { + if (keyword == null || keyword.trim().isEmpty()) { + return getPerformancesForAdminWithCursor(cursor, size); + } + + Pageable pageable = PageRequest.of(0, size + 1); + List performances = performanceRepository + .searchAllWithCursor(keyword.trim(), cursor, pageable); + + return mapToCursorRes(performances, size); + } + + /* + * ========================= + * 공통 유틸 (Private) + * ========================= + */ + + private PerformanceCursorPageRes mapToCursorRes(List performances, int size) { + LocalDateTime now = LocalDateTime.now(); + List content = performances.stream() + .map(p -> performanceMapper.toListRes(p, now)) + .toList(); + + return PerformanceCursorPageRes.of(content, size); + } + + private String blankToNull(String v) { + if (v == null) + return null; + String t = v.trim(); + return t.isEmpty() ? null : t; } /** - * 공연 검색 (판매중 + 키워드) + * posterKey 정규화 (저장 단계에서 수행) + * + * - null/blank 처리 + * - 선행/후행 슬래시 제거 + * - 중간 연속 슬래시 축약 (정규식/replace 루프 미사용) */ - public Page searchOnSalePerformances( - String keyword, - Pageable pageable - ) { - return performanceRepository - .searchOnSale(PerformanceStatus.ON_SALE, keyword, pageable) - .map(PerformanceListRes::from); + private String normalizePosterKey(String posterKey) { + String s = blankToNull(posterKey); + if (s == null) + return null; + + int start = 0; + int end = s.length(); + + while (start < end && s.charAt(start) == '/') + start++; + while (start < end && s.charAt(end - 1) == '/') + end--; + + if (start >= end) + return null; + + StringBuilder sb = new StringBuilder(end - start); + boolean prevSlash = false; + + for (int i = start; i < end; i++) { + char ch = s.charAt(i); + if (ch == '/') { + if (prevSlash) + continue; + prevSlash = true; + } else { + prevSlash = false; + } + sb.append(ch); + } + + return sb.length() == 0 ? null : sb.toString(); } } diff --git a/src/main/java/com/back/b2st/domain/performanceschedule/entity/BookingType.java b/src/main/java/com/back/b2st/domain/performanceschedule/entity/BookingType.java index 3fb7f4244..9ea3fa87a 100644 --- a/src/main/java/com/back/b2st/domain/performanceschedule/entity/BookingType.java +++ b/src/main/java/com/back/b2st/domain/performanceschedule/entity/BookingType.java @@ -1,7 +1,7 @@ package com.back.b2st.domain.performanceschedule.entity; public enum BookingType { - FIRST_COME, // 선착순 - SEAT, // 구역/좌석 기반(순차 등) - LOTTERY // 추첨 + FIRST_COME, // 일반 예매 + PRERESERVE, // 구역별 사전 신청 예매 + LOTTERY // 추첨 예매 } diff --git a/src/main/java/com/back/b2st/domain/performanceschedule/entity/PerformanceSchedule.java b/src/main/java/com/back/b2st/domain/performanceschedule/entity/PerformanceSchedule.java index 53f936772..d57c32344 100644 --- a/src/main/java/com/back/b2st/domain/performanceschedule/entity/PerformanceSchedule.java +++ b/src/main/java/com/back/b2st/domain/performanceschedule/entity/PerformanceSchedule.java @@ -33,9 +33,15 @@ @Index(name = "idx_performance_schedule", columnList = "performance_schedule_id, performance_id" ), + @Index( + name = "idx_performance_start_at", + columnList = "performance_id, start_at" + ), @Index(name = "idx_performance_booking_close_draw", columnList = "booking_close_at, draw_completed" - ) + ), + @Index(name = "idx_draw", columnList = "draw_completed"), + @Index(name = "idx_seat_allocated", columnList = "seat_allocated") } ) @SequenceGenerator( @@ -75,6 +81,9 @@ public class PerformanceSchedule extends BaseEntity { @Column(name = "draw_completed") private boolean drawCompleted; // 추첨 완료 여부 + @Column(name = "seat_allocated") + private boolean seatAllocated; // 좌석 배정 여부 + @Builder public PerformanceSchedule( Performance performance, @@ -91,5 +100,10 @@ public PerformanceSchedule( this.bookingOpenAt = bookingOpenAt; this.bookingCloseAt = bookingCloseAt; this.drawCompleted = false; + this.seatAllocated = false; + } + + public void markSeatAllocated() { + this.seatAllocated = true; } } diff --git a/src/main/java/com/back/b2st/domain/performanceschedule/repository/PerformanceScheduleRepository.java b/src/main/java/com/back/b2st/domain/performanceschedule/repository/PerformanceScheduleRepository.java index a9b8eb909..d96f73d31 100644 --- a/src/main/java/com/back/b2st/domain/performanceschedule/repository/PerformanceScheduleRepository.java +++ b/src/main/java/com/back/b2st/domain/performanceschedule/repository/PerformanceScheduleRepository.java @@ -17,6 +17,17 @@ public interface PerformanceScheduleRepository extends JpaRepository findAllByPerformance_PerformanceIdOrderByStartAtAsc(Long performanceId); + @Query(""" + select ps.performanceScheduleId + from PerformanceSchedule ps + where ps.performance.performanceId = :performanceId + """) + List findIdsByPerformanceId(@Param("performanceId") Long performanceId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from PerformanceSchedule ps where ps.performance.performanceId = :performanceId") + void deleteAllByPerformanceId(@Param("performanceId") Long performanceId); + Optional findByPerformance_PerformanceIdAndPerformanceScheduleId( Long performanceId, Long performanceScheduleId @@ -50,16 +61,24 @@ boolean existsByPerformanceAndScheduleMatch( ps.performanceScheduleId ) FROM PerformanceSchedule ps - JOIN ps.performance WHERE ps.bookingCloseAt >= :start AND ps.bookingCloseAt < :end AND ps.drawCompleted = false - AND ps.performance.performanceId = ps.performance.performanceId """) List findByClosedBetweenAndNotDrawn( @Param("start") LocalDateTime startDate, @Param("end") LocalDateTime endDate); + @Query(""" + SELECT new com.back.b2st.domain.performanceschedule.dto.DrawTargetPerformance( + ps.performance.performanceId, + ps.performanceScheduleId + ) + FROM PerformanceSchedule ps + WHERE ps.drawCompleted = false + """) + List findByNotDrawn(); + /** * 회차 추첨 완료 업데이트 * @param scheduleId @@ -71,4 +90,49 @@ List findByClosedBetweenAndNotDrawn( where ps.performanceScheduleId = :scheduleId """) void updateStautsById(@Param("scheduleId") Long scheduleId); + + /** + * 오늘부터 n일 이내에 시작하는 추첨 공연을 조회 - 좌석 배치 미진행 + */ + @Query(""" + SELECT new com.back.b2st.domain.performanceschedule.dto.DrawTargetPerformance( + ps.performance.performanceId, + ps.performanceScheduleId + ) + FROM PerformanceSchedule ps + JOIN ps.performance + WHERE ps.startAt >= :today + AND ps.startAt < :threeDaysLater + AND ps.bookingType = com.back.b2st.domain.performanceschedule.entity.BookingType.LOTTERY + AND ps.drawCompleted = true + AND ps.seatAllocated = false + """) + List findByOpenBetween( + @Param("today") LocalDateTime startDate, + @Param("threeDaysLater") LocalDateTime endDate); + + // test + @Query(""" + SELECT new com.back.b2st.domain.performanceschedule.dto.DrawTargetPerformance( + ps.performance.performanceId, + ps.performanceScheduleId + ) + FROM PerformanceSchedule ps + JOIN ps.performance + WHERE ps.bookingType = com.back.b2st.domain.performanceschedule.entity.BookingType.LOTTERY + AND ps.drawCompleted = true + AND ps.seatAllocated = false + """) + List findByOpenBetween(); + + /** + * scheduleId로 performanceId 조회 (대기열/검증 등에서 사용) + */ + @Query(""" + select ps.performance.performanceId + from PerformanceSchedule ps + where ps.performanceScheduleId = :scheduleId + """) + Optional findPerformanceIdByScheduleId(@Param("scheduleId") Long scheduleId); + } diff --git a/src/main/java/com/back/b2st/domain/performanceschedule/service/PerformanceScheduleService.java b/src/main/java/com/back/b2st/domain/performanceschedule/service/PerformanceScheduleService.java index 4cf37c8b4..826bcdcb4 100644 --- a/src/main/java/com/back/b2st/domain/performanceschedule/service/PerformanceScheduleService.java +++ b/src/main/java/com/back/b2st/domain/performanceschedule/service/PerformanceScheduleService.java @@ -12,8 +12,15 @@ import com.back.b2st.domain.performanceschedule.dto.response.PerformanceScheduleDetailRes; import com.back.b2st.domain.performanceschedule.dto.response.PerformanceScheduleListRes; import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.entity.BookingType; import com.back.b2st.domain.performanceschedule.error.PerformanceScheduleErrorCode; import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.policy.service.PrereservationTimeTableService; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.error.ScheduleSeatErrorCode; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.domain.seat.seat.entity.Seat; +import com.back.b2st.domain.seat.seat.repository.SeatRepository; import com.back.b2st.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -26,6 +33,10 @@ public class PerformanceScheduleService { private final PerformanceRepository performanceRepository; private final PerformanceScheduleRepository performanceScheduleRepository; + private final SeatRepository seatRepository; + private final ScheduleSeatRepository scheduleSeatRepository; + private final PrereservationTimeTableService prereservationTimeTableService; + /** * 회차 생성 (관리자) * - performanceId는 URL Path를 단일 기준으로 사용한다. @@ -33,7 +44,7 @@ public class PerformanceScheduleService { @Transactional public PerformanceScheduleCreateRes createSchedule(Long performanceId, PerformanceScheduleCreateReq request) { Performance performance = performanceRepository.findById(performanceId) - .orElseThrow(() -> new BusinessException(PerformanceScheduleErrorCode.PERFORMANCE_NOT_FOUND)); + .orElseThrow(() -> new BusinessException(PerformanceScheduleErrorCode.PERFORMANCE_NOT_FOUND)); // 시간/라운드 중복 방지 규칙은 추후 repository 기반으로 여기서 검증 // validateDuplicatedRoundNo(performanceId, request.roundNo()); @@ -45,18 +56,50 @@ public PerformanceScheduleCreateRes createSchedule(Long performanceId, Performan validateBookingTime(request); PerformanceSchedule schedule = PerformanceSchedule.builder() - .performance(performance) - .startAt(request.startAt()) - .roundNo(request.roundNo()) - .bookingType(request.bookingType()) - .bookingOpenAt(request.bookingOpenAt()) - .bookingCloseAt(request.bookingCloseAt()) - .build(); + .performance(performance) + .startAt(request.startAt()) + .roundNo(request.roundNo()) + .bookingType(request.bookingType()) + .bookingOpenAt(request.bookingOpenAt()) + .bookingCloseAt(request.bookingCloseAt()) + .build(); PerformanceSchedule saved = performanceScheduleRepository.save(schedule); + + if (saved.getBookingType() == BookingType.PRERESERVE) { + prereservationTimeTableService.ensureDefaultTimeTablesIfMissing(saved.getPerformanceScheduleId()); + } + + createScheduleSeats(saved.getPerformanceScheduleId(), performance.getVenue().getVenueId()); + return PerformanceScheduleCreateRes.from(saved); } + /** + * 회차별 좌석(ScheduleSeat) 생성 + */ + private void createScheduleSeats(Long scheduleId, Long venueId) { + + // 이미 생성된 회차 좌석이 있으면 재생성하지 않음 (중복 방지) + if (scheduleSeatRepository.existsByScheduleId(scheduleId)) { + return; + } + + List seats = seatRepository.findByVenueId(venueId); + if (seats.isEmpty()) { + throw new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_FOUND); + } + + List scheduleSeats = seats.stream() + .map(seat -> ScheduleSeat.builder() + .scheduleId(scheduleId) + .seatId(seat.getId()) + .build()) + .toList(); + + scheduleSeatRepository.saveAll(scheduleSeats); + } + /** * 공연별 회차 목록 조회 */ @@ -66,10 +109,10 @@ public List getSchedules(Long performanceId) { } return performanceScheduleRepository - .findAllByPerformance_PerformanceIdOrderByStartAtAsc(performanceId) - .stream() - .map(PerformanceScheduleListRes::from) - .toList(); + .findAllByPerformance_PerformanceIdOrderByStartAtAsc(performanceId) + .stream() + .map(PerformanceScheduleListRes::from) + .toList(); } /** @@ -78,8 +121,8 @@ public List getSchedules(Long performanceId) { */ public PerformanceScheduleDetailRes getSchedule(Long performanceId, Long scheduleId) { PerformanceSchedule schedule = performanceScheduleRepository - .findByPerformance_PerformanceIdAndPerformanceScheduleId(performanceId, scheduleId) - .orElseThrow(() -> new BusinessException(PerformanceScheduleErrorCode.SCHEDULE_NOT_FOUND)); + .findByPerformance_PerformanceIdAndPerformanceScheduleId(performanceId, scheduleId) + .orElseThrow(() -> new BusinessException(PerformanceScheduleErrorCode.SCHEDULE_NOT_FOUND)); return PerformanceScheduleDetailRes.from(schedule); } diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/aop/PrereservationHoldAspect.java b/src/main/java/com/back/b2st/domain/prereservation/booking/aop/PrereservationHoldAspect.java new file mode 100644 index 000000000..0b4113b26 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/aop/PrereservationHoldAspect.java @@ -0,0 +1,29 @@ +package com.back.b2st.domain.prereservation.booking.aop; + +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.prereservation.booking.service.PrereservationHoldService; + +import lombok.RequiredArgsConstructor; + +@Deprecated +@Aspect +// @Component +@Order(0) +@RequiredArgsConstructor +public class PrereservationHoldAspect { + + private final PrereservationHoldService prereservationHoldService; + + @Before( + value = "execution(* com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService.holdSeat(..))" + + " && args(memberId, scheduleId, seatId)", + argNames = "memberId,scheduleId,seatId" + ) + public void validatePrereservationHoldAllowed(Long memberId, Long scheduleId, Long seatId) { + prereservationHoldService.validateSeatHoldAllowed(memberId, scheduleId, seatId); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/controller/PrereservationBookingController.java b/src/main/java/com/back/b2st/domain/prereservation/booking/controller/PrereservationBookingController.java new file mode 100644 index 000000000..d6075e1d1 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/controller/PrereservationBookingController.java @@ -0,0 +1,94 @@ +package com.back.b2st.domain.prereservation.booking.controller; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.prereservation.booking.dto.response.PrereservationBookingCreateRes; +import com.back.b2st.domain.prereservation.booking.service.PrereservationSeatIdResolver; +import com.back.b2st.domain.prereservation.booking.service.PrereservationBookingService; +import com.back.b2st.domain.prereservation.booking.service.PrereservationHoldService; +import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService; +import com.back.b2st.global.annotation.CurrentUser; +import com.back.b2st.global.common.BaseResponse; +import com.back.b2st.global.error.code.CommonErrorCode; +import com.back.b2st.global.error.exception.BusinessException; +import com.back.b2st.security.UserPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/prereservations/schedules/{scheduleId}/seats/{seatId}") +@Tag(name = "신청 예매", description = "신청 예매 전용 좌석 선택 및 예매 생성 API") +public class PrereservationBookingController { + + private final PrereservationBookingService prereservationBookingService; + private final PrereservationHoldService prereservationHoldService; + private final PrereservationSeatIdResolver prereservationSeatIdResolver; + private final ScheduleSeatStateService scheduleSeatStateService; + + @PostMapping("/hold") + @Operation( + summary = "신청예매 좌석 선택 (HOLD)", + description = """ + 신청예매 전용 좌석 HOLD API입니다. + - bookingType=PRERESERVE 회차만 가능 + - 신청한 구역인지 검증 + - 구역별 예매 시간대 검증 + - 예매 오픈 시간 검증 + - 검증 통과 후 좌석 HOLD + """ + ) + public BaseResponse holdSeat( + @Parameter(hidden = true) @CurrentUser UserPrincipal user, + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + @Parameter(description = "좌석 ID", example = "101") + @PathVariable Long seatId + ) { + Long memberId = requireMemberId(user); + Long resolvedSeatId = prereservationSeatIdResolver.resolveSeatId(scheduleId, seatId); + + // 1. 신청예매 검증 (구역, 시간대 등) + prereservationHoldService.validateSeatHoldAllowed(memberId, scheduleId, resolvedSeatId); + + // 2. 좌석 HOLD + scheduleSeatStateService.holdSeatWithoutQueue(memberId, scheduleId, resolvedSeatId); + + return BaseResponse.created(null); + } + + @PostMapping("/bookings") + @Operation( + summary = "신청예매 예매 생성(결제 시작)", + description = """ + HOLD된 좌석으로 신청예매 전용 예매를 생성합니다. + - bookingType=PRERESERVE 회차만 가능 + - 좌석 HOLD 소유권(memberId) 검증 + - 생성된 prereservationBookingId를 결제 domainId로 사용합니다(domainType=PRERESERVATION) + """ + ) + public BaseResponse createBooking( + @Parameter(hidden = true) @CurrentUser UserPrincipal user, + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + @Parameter(description = "좌석 ID", example = "101") + @PathVariable Long seatId + ) { + Long resolvedSeatId = prereservationSeatIdResolver.resolveSeatId(scheduleId, seatId); + var booking = prereservationBookingService.createBooking(requireMemberId(user), scheduleId, resolvedSeatId); + return BaseResponse.created(new PrereservationBookingCreateRes(booking.getId(), booking.getExpiresAt())); + } + + private Long requireMemberId(UserPrincipal user) { + if (user == null) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + } + return user.getId(); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/dto/response/PrereservationBookingCreateRes.java b/src/main/java/com/back/b2st/domain/prereservation/booking/dto/response/PrereservationBookingCreateRes.java new file mode 100644 index 000000000..d295383ca --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/dto/response/PrereservationBookingCreateRes.java @@ -0,0 +1,10 @@ +package com.back.b2st.domain.prereservation.booking.dto.response; + +import java.time.LocalDateTime; + +public record PrereservationBookingCreateRes( + Long prereservationBookingId, + LocalDateTime expiresAt +) { +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/entity/PrereservationBooking.java b/src/main/java/com/back/b2st/domain/prereservation/booking/entity/PrereservationBooking.java new file mode 100644 index 000000000..ff79c64d7 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/entity/PrereservationBooking.java @@ -0,0 +1,89 @@ +package com.back.b2st.domain.prereservation.booking.entity; + +import java.time.LocalDateTime; + +import com.back.b2st.global.jpa.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "prereservation_booking", + indexes = { + @Index(name = "idx_prereservation_booking_member", columnList = "member_id"), + @Index(name = "idx_prereservation_booking_schedule_seat", columnList = "schedule_seat_id") + } +) +@SequenceGenerator( + name = "prereservation_booking_id_gen", + sequenceName = "prereservation_booking_seq", + allocationSize = 50 +) +public class PrereservationBooking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "prereservation_booking_id_gen") + @Column(name = "prereservation_booking_id") + private Long id; + + @Column(name = "schedule_id", nullable = false) + private Long scheduleId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "schedule_seat_id", nullable = false) + private Long scheduleSeatId; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PrereservationBookingStatus status; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "canceled_at") + private LocalDateTime canceledAt; + + @Builder + public PrereservationBooking(Long scheduleId, Long memberId, Long scheduleSeatId, LocalDateTime expiresAt) { + this.scheduleId = scheduleId; + this.memberId = memberId; + this.scheduleSeatId = scheduleSeatId; + this.expiresAt = expiresAt; + this.status = PrereservationBookingStatus.CREATED; + } + + public void complete(LocalDateTime completedAt) { + this.status = PrereservationBookingStatus.COMPLETED; + this.completedAt = completedAt; + } + + public void cancel(LocalDateTime canceledAt) { + this.status = PrereservationBookingStatus.CANCELED; + this.canceledAt = canceledAt; + } + + public void fail() { + this.status = PrereservationBookingStatus.FAILED; + } +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/entity/PrereservationBookingStatus.java b/src/main/java/com/back/b2st/domain/prereservation/booking/entity/PrereservationBookingStatus.java new file mode 100644 index 000000000..794dc5171 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/entity/PrereservationBookingStatus.java @@ -0,0 +1,9 @@ +package com.back.b2st.domain.prereservation.booking.entity; + +public enum PrereservationBookingStatus { + CREATED, + COMPLETED, + CANCELED, + FAILED +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/repository/PrereservationBookingRepository.java b/src/main/java/com/back/b2st/domain/prereservation/booking/repository/PrereservationBookingRepository.java new file mode 100644 index 000000000..95cc7adcf --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/repository/PrereservationBookingRepository.java @@ -0,0 +1,71 @@ +package com.back.b2st.domain.prereservation.booking.repository; + +import java.util.List; +import java.util.Optional; +import java.time.LocalDateTime; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBooking; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBookingStatus; + +import jakarta.persistence.LockModeType; + +@Repository +public interface PrereservationBookingRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select b from PrereservationBooking b where b.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + @Query(""" + select b + from PrereservationBooking b + where b.scheduleSeatId = :scheduleSeatId + and b.status in :activeStatuses + """) + Optional findActiveByScheduleSeatId( + @Param("scheduleSeatId") Long scheduleSeatId, + @Param("activeStatuses") List activeStatuses + ); + + @Query(""" + select b + from PrereservationBooking b + where b.scheduleSeatId = :scheduleSeatId + and b.status in :activeStatuses + and b.expiresAt > :now + """) + Optional findActiveByScheduleSeatIdAndNotExpired( + @Param("scheduleSeatId") Long scheduleSeatId, + @Param("activeStatuses") List activeStatuses, + @Param("now") LocalDateTime now + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update PrereservationBooking b + set b.status = :expiredStatus + where b.status = :targetStatus + and b.expiresAt <= :now + """) + int expireCreatedBookingsBatch( + @Param("targetStatus") PrereservationBookingStatus targetStatus, + @Param("expiredStatus") PrereservationBookingStatus expiredStatus, + @Param("now") LocalDateTime now + ); + + @Query(""" + select b.id + from PrereservationBooking b + where b.scheduleId in :scheduleIds + """) + List findIdsByScheduleIdIn(@Param("scheduleIds") List scheduleIds); + + void deleteAllByScheduleIdIn(List scheduleIds); +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/scheduler/PrereservationBookingScheduler.java b/src/main/java/com/back/b2st/domain/prereservation/booking/scheduler/PrereservationBookingScheduler.java new file mode 100644 index 000000000..7e278193d --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/scheduler/PrereservationBookingScheduler.java @@ -0,0 +1,32 @@ +package com.back.b2st.domain.prereservation.booking.scheduler; + +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.prereservation.booking.service.PrereservationBookingService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@Profile("!test") +public class PrereservationBookingScheduler { + + private final PrereservationBookingService prereservationBookingService; + + @Scheduled(fixedDelayString = "${scheduler.prereservation-booking-expire.delay-ms:5000}") + public void expireCreatedBookingsBatch() { + try { + int expired = prereservationBookingService.expireCreatedBookingsBatch(); + if (expired > 0) { + log.info("스케줄러 처리 결과 - 만료된 신청예매(prereservationBooking)={}건", expired); + } + } catch (Exception e) { + log.error("스케줄러 처리 중 오류가 발생했습니다. (신청예매 만료)", e); + } + } +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationBookingService.java b/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationBookingService.java new file mode 100644 index 000000000..1fc49b750 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationBookingService.java @@ -0,0 +1,116 @@ +package com.back.b2st.domain.prereservation.booking.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.payment.error.PaymentErrorCode; +import com.back.b2st.domain.performanceschedule.entity.BookingType; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBooking; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBookingStatus; +import com.back.b2st.domain.prereservation.booking.repository.PrereservationBookingRepository; +import com.back.b2st.domain.prereservation.entry.error.PrereservationErrorCode; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService; +import com.back.b2st.domain.scheduleseat.service.SeatHoldTokenService; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrereservationBookingService { + + private final PerformanceScheduleRepository performanceScheduleRepository; + private final ScheduleSeatRepository scheduleSeatRepository; + private final SeatHoldTokenService seatHoldTokenService; + private final PrereservationBookingRepository prereservationBookingRepository; + private final ScheduleSeatStateService scheduleSeatStateService; + + @Transactional + public PrereservationBooking createBooking(Long memberId, Long scheduleId, Long seatId) { + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SCHEDULE_NOT_FOUND)); + + if (schedule.getBookingType() != BookingType.PRERESERVE) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TYPE_NOT_SUPPORTED); + } + + ScheduleSeat scheduleSeat = scheduleSeatRepository.findByScheduleIdAndSeatId(scheduleId, seatId) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + if (scheduleSeat.getStatus() != SeatStatus.HOLD) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); + } + + seatHoldTokenService.validateOwnership(scheduleId, seatId, memberId); + + LocalDateTime now = LocalDateTime.now(); + + if (scheduleSeat.getHoldExpiredAt() == null) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE); + } + + if (scheduleSeat.getHoldExpiredAt().isBefore(now)) { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "좌석 HOLD가 만료되었습니다."); + } + + prereservationBookingRepository.findActiveByScheduleSeatIdAndNotExpired( + scheduleSeat.getId(), + List.of(PrereservationBookingStatus.CREATED), + now + ) + .ifPresent(existing -> { + throw new BusinessException(PaymentErrorCode.DOMAIN_NOT_PAYABLE, "이미 생성된 신청예매가 존재합니다."); + }); + + return prereservationBookingRepository.save( + PrereservationBooking.builder() + .scheduleId(scheduleId) + .memberId(memberId) + .scheduleSeatId(scheduleSeat.getId()) + .expiresAt(scheduleSeat.getHoldExpiredAt()) + .build() + ); + } + + @Transactional + public int expireCreatedBookingsBatch() { + return prereservationBookingRepository.expireCreatedBookingsBatch( + PrereservationBookingStatus.CREATED, + PrereservationBookingStatus.FAILED, + LocalDateTime.now() + ); + } + + @Transactional(readOnly = true) + public PrereservationBooking getBookingOrThrow(Long bookingId) { + return prereservationBookingRepository.findById(bookingId) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + } + + @Transactional + public void failBooking(Long bookingId) { + PrereservationBooking booking = prereservationBookingRepository.findByIdWithLock(bookingId) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + if (booking.getStatus() == PrereservationBookingStatus.FAILED + || booking.getStatus() == PrereservationBookingStatus.CANCELED + || booking.getStatus() == PrereservationBookingStatus.COMPLETED) { + return; + } + + booking.fail(); + + ScheduleSeat scheduleSeat = scheduleSeatRepository.findById(booking.getScheduleSeatId()) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.DOMAIN_NOT_FOUND)); + + scheduleSeatStateService.releaseHold(scheduleSeat.getScheduleId(), scheduleSeat.getSeatId()); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationHoldService.java b/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationHoldService.java new file mode 100644 index 000000000..ad1eda609 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationHoldService.java @@ -0,0 +1,93 @@ +package com.back.b2st.domain.prereservation.booking.service; + +import java.time.LocalDateTime; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.performanceschedule.entity.BookingType; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.entry.error.PrereservationErrorCode; +import com.back.b2st.domain.prereservation.entry.repository.PrereservationRepository; +import com.back.b2st.domain.prereservation.policy.service.PrereservationTimeTableService; +import com.back.b2st.domain.prereservation.policy.service.PrereservationSlotService; +import com.back.b2st.domain.scheduleseat.error.ScheduleSeatErrorCode; +import com.back.b2st.domain.seat.seat.entity.Seat; +import com.back.b2st.domain.seat.seat.repository.SeatRepository; +import com.back.b2st.domain.venue.section.entity.Section; +import com.back.b2st.domain.venue.section.repository.SectionRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrereservationHoldService { + + private final PerformanceScheduleRepository performanceScheduleRepository; + private final SeatRepository seatRepository; + private final SectionRepository sectionRepository; + private final PrereservationRepository prereservationRepository; + private final PrereservationSlotService prereservationSlotService; + private final PrereservationTimeTableService prereservationTimeTableService; + + @Value("${prereservation.booking.strict:true}") + private boolean bookingStrict = true; + + @Value("${prereservation.slot.strict:true}") + private boolean slotStrict = true; + + @Transactional + public void validateSeatHoldAllowed(Long memberId, Long scheduleId, Long seatId) { + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SCHEDULE_NOT_FOUND)); + + if (schedule.getBookingType() != BookingType.PRERESERVE) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TYPE_NOT_SUPPORTED); + } + + LocalDateTime bookingOpenAt = schedule.getBookingOpenAt(); + if (bookingOpenAt == null) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TIME_NOT_CONFIGURED); + } + + LocalDateTime now = LocalDateTime.now(); + if (bookingStrict) { + if (now.isBefore(bookingOpenAt)) { + throw new BusinessException(PrereservationErrorCode.BOOKING_NOT_OPEN); + } + if (schedule.getBookingCloseAt() != null && now.isAfter(schedule.getBookingCloseAt())) { + throw new BusinessException(PrereservationErrorCode.BOOKING_CLOSED); + } + } + + Seat seat = seatRepository.findById(seatId) + .orElseThrow(() -> new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_FOUND)); + + Long seatSectionId = seat.getSectionId(); + boolean applied = prereservationRepository.existsByPerformanceScheduleIdAndMemberIdAndSectionId( + scheduleId, + memberId, + seatSectionId + ); + if (!applied) { + throw new BusinessException(PrereservationErrorCode.SECTION_NOT_ACTIVATED); + } + + if (!slotStrict) { + return; + } + + prereservationTimeTableService.ensureDefaultTimeTablesIfMissing(scheduleId); + + Section section = sectionRepository.findById(seatSectionId) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SECTION_NOT_FOUND)); + + var slot = prereservationSlotService.calculateSlotOrThrow(schedule, section); + if (now.isBefore(slot.startAt()) || now.isAfter(slot.endAt())) { + throw new BusinessException(PrereservationErrorCode.BOOKING_SLOT_NOT_OPEN); + } + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationSeatIdResolver.java b/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationSeatIdResolver.java new file mode 100644 index 000000000..c366f984b --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/booking/service/PrereservationSeatIdResolver.java @@ -0,0 +1,34 @@ +package com.back.b2st.domain.prereservation.booking.service; + +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 프론트 연동에서 seatId 대신 scheduleSeatId가 넘어오는 경우를 흡수하기 위한 resolver. + * - 정상 케이스: seatId 그대로 반환 + * - 오입력 케이스: scheduleSeatId → seatId로 변환 + */ +@Component +@RequiredArgsConstructor +public class PrereservationSeatIdResolver { + + private final ScheduleSeatRepository scheduleSeatRepository; + + public Long resolveSeatId(Long scheduleId, Long seatIdOrScheduleSeatId) { + // 정상 케이스(= seatId)면 scheduleId+seatId 매핑이 존재한다. + if (scheduleSeatRepository.findByScheduleIdAndSeatId(scheduleId, seatIdOrScheduleSeatId).isPresent()) { + return seatIdOrScheduleSeatId; + } + + // 오입력 케이스(= scheduleSeatId)면 PK로 조회해서 seatId로 변환한다. + return scheduleSeatRepository.findById(seatIdOrScheduleSeatId) + .filter(scheduleSeat -> scheduleId.equals(scheduleSeat.getScheduleId())) + .map(ScheduleSeat::getSeatId) + .orElse(seatIdOrScheduleSeatId); + } +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/controller/PrereservationController.java b/src/main/java/com/back/b2st/domain/prereservation/entry/controller/PrereservationController.java new file mode 100644 index 000000000..502829ef9 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/controller/PrereservationController.java @@ -0,0 +1,95 @@ +package com.back.b2st.domain.prereservation.entry.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.prereservation.entry.dto.request.PrereservationReq; +import com.back.b2st.domain.prereservation.entry.dto.response.PrereservationRes; +import com.back.b2st.domain.prereservation.entry.dto.response.PrereservationSectionRes; +import com.back.b2st.domain.prereservation.entry.service.PrereservationApplyService; +import com.back.b2st.domain.prereservation.entry.service.PrereservationSectionService; +import com.back.b2st.global.annotation.CurrentUser; +import com.back.b2st.global.common.BaseResponse; +import com.back.b2st.global.error.code.CommonErrorCode; +import com.back.b2st.global.error.exception.BusinessException; +import com.back.b2st.security.UserPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/prereservations/schedules/{scheduleId}/applications") +@Tag(name = "신청 예매", description = "사전 구역 신청 및 신청 내역 조회 API") +public class PrereservationController { + + private final PrereservationApplyService prereservationApplyService; + private final PrereservationSectionService prereservationSectionService; + + @PostMapping + @Operation( + summary = "사전 구역 신청", + description = """ + BookingType이 PRERESERVE(신청 예매)인 회차에 대해 예매 가능한 구역을 사전에 신청합니다. + - 예매 오픈 날짜 기준 전날 00:00부터 신청 가능 + - 예매 오픈 날짜 기준 당일 00:00 이후 신청 불가 + - 동일 회차/구역 중복 신청 불가 + """ + ) + public BaseResponse apply( + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + @Parameter(hidden = true) @CurrentUser UserPrincipal user, + @Valid @RequestBody PrereservationReq request + ) { + Long memberId = requireMemberId(user); + prereservationApplyService.apply(scheduleId, memberId, user.getEmail(), request.sectionId()); + return BaseResponse.created(null); + } + + @GetMapping("/sections") + @Operation( + summary = "회차별 구역 목록 조회(신청 예매)", + description = """ + 특정 회차의 공연장 구역 목록을 조회합니다. + - 각 구역별 예매 가능 시간대(고정 슬롯) 포함 + - 로그인 사용자 기준 applied=true/false 포함 + """ + ) + public BaseResponse> getSections( + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + @Parameter(hidden = true) @CurrentUser UserPrincipal user + ) { + return BaseResponse.success(prereservationSectionService.getSections(scheduleId, requireMemberId(user))); + } + + @GetMapping("/me") + @Operation( + summary = "내 사전 신청 조회", + description = "로그인한 사용자의 특정 회차 사전 신청 구역 목록을 조회합니다." + ) + public BaseResponse getMyApplications( + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + @Parameter(hidden = true) @CurrentUser UserPrincipal user + ) { + return BaseResponse.success(prereservationApplyService.getMyApplications(scheduleId, requireMemberId(user))); + } + + private Long requireMemberId(UserPrincipal user) { + if (user == null) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + } + return user.getId(); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/controller/PrereservationMyApplicationController.java b/src/main/java/com/back/b2st/domain/prereservation/entry/controller/PrereservationMyApplicationController.java new file mode 100644 index 000000000..f5d14f599 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/controller/PrereservationMyApplicationController.java @@ -0,0 +1,66 @@ +package com.back.b2st.domain.prereservation.entry.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.prereservation.entry.dto.response.PrereservationRes; +import com.back.b2st.domain.prereservation.entry.service.PrereservationApplyService; +import com.back.b2st.global.annotation.CurrentUser; +import com.back.b2st.global.common.BaseResponse; +import com.back.b2st.global.error.code.CommonErrorCode; +import com.back.b2st.global.error.exception.BusinessException; +import com.back.b2st.security.UserPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/prereservations/applications") +@Tag(name = "신청 예매", description = "사전 구역 신청 및 신청 내역 조회 API") +public class PrereservationMyApplicationController { + + private final PrereservationApplyService prereservationApplyService; + + @GetMapping("/me") + @Operation( + summary = "내 사전 신청 전체 조회", + description = """ + 로그인한 사용자의 전체 회차 사전 신청 구역 목록을 조회합니다. + + # REQUEST + GET /api/prereservations/applications/me + + # RESPONSE (200 OK) + { + "code": 200, + "message": "성공적으로 처리되었습니다", + "data": [ + { "scheduleId": 1, "sectionIds": [1, 3], "bookingOpenAt": "2026-01-05T09:00:00", "bookingCloseAt": "2026-02-04T09:00:00" }, + { "scheduleId": 2, "sectionIds": [5], "bookingOpenAt": "2026-01-06T09:00:00", "bookingCloseAt": "2026-02-05T09:00:00" } + ] + } + """ + ) + @SecurityRequirement(name = "Authorization") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "내 사전 신청 전체 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)") + }) + public BaseResponse> getMyApplications( + @Parameter(hidden = true) @CurrentUser UserPrincipal user + ) { + if (user == null) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + } + return BaseResponse.success(prereservationApplyService.getMyApplicationList(user.getId())); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/dto/request/PrereservationReq.java b/src/main/java/com/back/b2st/domain/prereservation/entry/dto/request/PrereservationReq.java new file mode 100644 index 000000000..09827aca2 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/dto/request/PrereservationReq.java @@ -0,0 +1,8 @@ +package com.back.b2st.domain.prereservation.entry.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record PrereservationReq( + @NotNull Long sectionId +) { +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/dto/response/PrereservationRes.java b/src/main/java/com/back/b2st/domain/prereservation/entry/dto/response/PrereservationRes.java new file mode 100644 index 000000000..ce5ddfe7c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/dto/response/PrereservationRes.java @@ -0,0 +1,24 @@ +package com.back.b2st.domain.prereservation.entry.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record PrereservationRes( + Long scheduleId, + List sectionIds, + LocalDateTime bookingOpenAt, + LocalDateTime bookingCloseAt +) { + public static PrereservationRes of( + Long scheduleId, + List sectionIds, + LocalDateTime bookingOpenAt, + LocalDateTime bookingCloseAt + ) { + return new PrereservationRes(scheduleId, sectionIds, bookingOpenAt, bookingCloseAt); + } + + public static PrereservationRes of(Long scheduleId, List sectionIds) { + return new PrereservationRes(scheduleId, sectionIds, null, null); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/dto/response/PrereservationSectionRes.java b/src/main/java/com/back/b2st/domain/prereservation/entry/dto/response/PrereservationSectionRes.java new file mode 100644 index 000000000..9d0fb93a7 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/dto/response/PrereservationSectionRes.java @@ -0,0 +1,12 @@ +package com.back.b2st.domain.prereservation.entry.dto.response; + +import java.time.LocalDateTime; + +public record PrereservationSectionRes( + Long sectionId, + String sectionName, + LocalDateTime bookingStartAt, + LocalDateTime bookingEndAt, + boolean applied +) { +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/entity/Prereservation.java b/src/main/java/com/back/b2st/domain/prereservation/entry/entity/Prereservation.java new file mode 100644 index 000000000..2b75bbea9 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/entity/Prereservation.java @@ -0,0 +1,57 @@ +package com.back.b2st.domain.prereservation.entry.entity; + +import com.back.b2st.global.jpa.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "seat_section_applications", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_seat_section_applications_schedule_member_section", + columnNames = {"performance_schedule_id", "member_id", "section_id"} + ) + } +) +@SequenceGenerator( + name = "seat_section_applications_id_gen", + sequenceName = "seat_section_applications_seq", + allocationSize = 50 +) +public class Prereservation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seat_section_applications_id_gen") + @Column(name = "seat_section_application_id") + private Long id; + + @Column(name = "performance_schedule_id", nullable = false) + private Long performanceScheduleId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "section_id", nullable = false) + private Long sectionId; + + @Builder + public Prereservation(Long performanceScheduleId, Long memberId, Long sectionId) { + this.performanceScheduleId = performanceScheduleId; + this.memberId = memberId; + this.sectionId = sectionId; + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/error/PrereservationErrorCode.java b/src/main/java/com/back/b2st/domain/prereservation/entry/error/PrereservationErrorCode.java new file mode 100644 index 000000000..be3d2d34e --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/error/PrereservationErrorCode.java @@ -0,0 +1,34 @@ +package com.back.b2st.domain.prereservation.entry.error; + +import org.springframework.http.HttpStatus; + +import com.back.b2st.global.error.code.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PrereservationErrorCode implements ErrorCode { + + SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "PR001", "공연 회차를 찾을 수 없습니다."), + BOOKING_TYPE_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "PR002", "해당 회차는 신청 예매 대상이 아닙니다."), + APPLICATION_CLOSED(HttpStatus.CONFLICT, "PR003", "신청 예매 기간이 종료되었습니다."), + SECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "PR004", "구역을 찾을 수 없습니다."), + SECTION_NOT_IN_VENUE(HttpStatus.BAD_REQUEST, "PR005", "해당 공연장의 구역이 아닙니다."), + DUPLICATE_APPLICATION(HttpStatus.CONFLICT, "PR006", "이미 신청한 구역입니다."), + SECTION_NOT_ACTIVATED(HttpStatus.FORBIDDEN, "PR007", "신청한 구역만 예매할 수 있습니다."), + BOOKING_NOT_OPEN(HttpStatus.FORBIDDEN, "PR008", "예매 오픈 전입니다."), + BOOKING_CLOSED(HttpStatus.FORBIDDEN, "PR009", "예매가 종료되었습니다."), + + APPLICATION_NOT_OPEN(HttpStatus.FORBIDDEN, "PR010", "신청 예매 신청 기간이 아닙니다."), + BOOKING_SLOT_NOT_OPEN(HttpStatus.FORBIDDEN, "PR011", "현재 시간에는 해당 구역 예매가 불가능합니다."), + BOOKING_TIME_NOT_CONFIGURED(HttpStatus.CONFLICT, "PR012", "예매 시간이 설정되지 않았습니다."), + TIME_TABLE_NOT_CONFIGURED(HttpStatus.CONFLICT, "PR013", "구역별 예매 시간대가 올바르게 설정되지 않았습니다."), + RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "PR014", "이미 예매가 진행 중이거나 완료된 좌석입니다."), + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "PR015", "예매 정보를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/repository/PrereservationRepository.java b/src/main/java/com/back/b2st/domain/prereservation/entry/repository/PrereservationRepository.java new file mode 100644 index 000000000..95fbe2734 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/repository/PrereservationRepository.java @@ -0,0 +1,22 @@ +package com.back.b2st.domain.prereservation.entry.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.b2st.domain.prereservation.entry.entity.Prereservation; + +public interface PrereservationRepository extends JpaRepository { + + boolean existsByPerformanceScheduleIdAndMemberIdAndSectionId(Long performanceScheduleId, Long memberId, + Long sectionId); + + List findAllByMemberIdOrderByCreatedAtDesc(Long memberId); + + List findAllByPerformanceScheduleIdAndMemberIdOrderByCreatedAtDesc( + Long performanceScheduleId, + Long memberId + ); + + void deleteAllByPerformanceScheduleIdIn(List performanceScheduleIds); +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/service/PrereservationApplyService.java b/src/main/java/com/back/b2st/domain/prereservation/entry/service/PrereservationApplyService.java new file mode 100644 index 000000000..aec2f0da8 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/service/PrereservationApplyService.java @@ -0,0 +1,204 @@ +package com.back.b2st.domain.prereservation.entry.service; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.email.service.EmailSender; +import com.back.b2st.domain.performanceschedule.entity.BookingType; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.entry.dto.response.PrereservationRes; +import com.back.b2st.domain.prereservation.entry.entity.Prereservation; +import com.back.b2st.domain.prereservation.entry.error.PrereservationErrorCode; +import com.back.b2st.domain.prereservation.entry.repository.PrereservationRepository; +import com.back.b2st.domain.prereservation.policy.service.PrereservationTimeTableService; +import com.back.b2st.domain.prereservation.policy.service.PrereservationSlotService; +import com.back.b2st.domain.venue.section.entity.Section; +import com.back.b2st.domain.venue.section.repository.SectionRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrereservationApplyService { + + private static final DateTimeFormatter EMAIL_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private final PerformanceScheduleRepository performanceScheduleRepository; + private final SectionRepository sectionRepository; + private final PrereservationRepository prereservationRepository; + private final PrereservationSlotService prereservationSlotService; + private final PrereservationTimeTableService prereservationTimeTableService; + private final EmailSender emailSender; + + @Value("${prereservation.application.strict:true}") + private boolean applicationStrict = true; + + @Value("${prereservation.slot.strict:true}") + private boolean slotStrict = true; + + @Value("${app.frontend.my-page-url:https://doncrytt.vercel.app/my-page}") + private String myPageUrl = "https://doncrytt.vercel.app/my-page"; + + @Transactional + public void apply(Long scheduleId, Long memberId, String email, Long sectionId) { + PerformanceSchedule schedule = getScheduleOrThrow(scheduleId); + + if (schedule.getBookingType() != BookingType.PRERESERVE) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TYPE_NOT_SUPPORTED); + } + + LocalDateTime bookingOpenAt = schedule.getBookingOpenAt(); + if (bookingOpenAt == null) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TIME_NOT_CONFIGURED); + } + + if (applicationStrict) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime applyOpenAt = bookingOpenAt.toLocalDate().minusDays(1).atStartOfDay(); + LocalDateTime applyCloseAt = bookingOpenAt.toLocalDate().atTime(LocalTime.MIDNIGHT); + if (now.isBefore(applyOpenAt)) { + throw new BusinessException(PrereservationErrorCode.APPLICATION_NOT_OPEN); + } + if (!now.isBefore(applyCloseAt)) { + throw new BusinessException(PrereservationErrorCode.APPLICATION_CLOSED); + } + } + + Section section = sectionRepository.findById(sectionId) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SECTION_NOT_FOUND)); + + Long venueId = schedule.getPerformance().getVenue().getVenueId(); + if (!section.getVenueId().equals(venueId)) { + throw new BusinessException(PrereservationErrorCode.SECTION_NOT_IN_VENUE); + } + + if (prereservationRepository.existsByPerformanceScheduleIdAndMemberIdAndSectionId( + scheduleId, memberId, sectionId)) { + throw new BusinessException(PrereservationErrorCode.DUPLICATE_APPLICATION); + } + + try { + prereservationRepository.save( + Prereservation.builder() + .performanceScheduleId(scheduleId) + .memberId(memberId) + .sectionId(sectionId) + .build() + ); + } catch (DataIntegrityViolationException e) { + throw new BusinessException(PrereservationErrorCode.DUPLICATE_APPLICATION); + } + + if (email != null && !email.isBlank()) { + var slot = slotStrict + ? calculateSlotOrEnsure(scheduleId, schedule, section) + : new PrereservationSlotService.Slot( + schedule.getBookingOpenAt(), + schedule.getBookingCloseAt() != null + ? schedule.getBookingCloseAt() + : schedule.getBookingOpenAt().plusDays(30) + ); + sendAppliedEmail(email, section.getSectionName(), slot.startAt(), slot.endAt()); + } + } + + private PrereservationSlotService.Slot calculateSlotOrEnsure( + Long scheduleId, + PerformanceSchedule schedule, + Section section + ) { + prereservationTimeTableService.ensureDefaultTimeTablesIfMissing(scheduleId); + return prereservationSlotService.calculateSlotOrThrow(schedule, section); + } + + @Transactional(readOnly = true) + public PrereservationRes getMyApplications(Long scheduleId, Long memberId) { + PerformanceSchedule schedule = getScheduleOrThrow(scheduleId); + + var applications = prereservationRepository + .findAllByPerformanceScheduleIdAndMemberIdOrderByCreatedAtDesc(scheduleId, memberId); + var sectionIds = applications.stream().map(Prereservation::getSectionId).distinct().toList(); + return PrereservationRes.of( + scheduleId, + sectionIds, + schedule.getBookingOpenAt(), + schedule.getBookingCloseAt() + ); + } + + @Transactional(readOnly = true) + public List getMyApplicationList(Long memberId) { + var applications = prereservationRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId); + + var sectionIdsByScheduleId = new TreeMap>(); + for (Prereservation application : applications) { + sectionIdsByScheduleId + .computeIfAbsent(application.getPerformanceScheduleId(), ignored -> new TreeSet<>()) + .add(application.getSectionId()); + } + + Map scheduleById = performanceScheduleRepository + .findAllById(sectionIdsByScheduleId.keySet()) + .stream() + .collect(java.util.stream.Collectors.toMap( + PerformanceSchedule::getPerformanceScheduleId, + schedule -> schedule + )); + + var response = new ArrayList(sectionIdsByScheduleId.size()); + for (var entry : sectionIdsByScheduleId.entrySet()) { + PerformanceSchedule schedule = scheduleById.get(entry.getKey()); + if (schedule == null || schedule.getBookingType() != BookingType.PRERESERVE) { + continue; + } + response.add(PrereservationRes.of( + entry.getKey(), + new ArrayList<>(entry.getValue()), + schedule.getBookingOpenAt(), + schedule.getBookingCloseAt() + )); + } + return response; + } + + private PerformanceSchedule getScheduleOrThrow(Long scheduleId) { + return performanceScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SCHEDULE_NOT_FOUND)); + } + + private void sendAppliedEmail(String email, String sectionName, LocalDateTime startAt, LocalDateTime endAt) { + String message = """ + 신청 예매 사전 신청이 완료되었습니다. + + - 신청 구역: %s + - 예매 가능 시간: %s ~ %s + + 해당 시간 외에는 예매가 불가능합니다. + """.formatted( + sectionName, + startAt.format(EMAIL_TIME_FORMATTER), + endAt.format(EMAIL_TIME_FORMATTER) + ); + + emailSender.sendNotificationEmail( + email, + "[TT] 신청 예매 사전 신청 완료", + message, + "예매 바로가기", + myPageUrl + ); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/entry/service/PrereservationSectionService.java b/src/main/java/com/back/b2st/domain/prereservation/entry/service/PrereservationSectionService.java new file mode 100644 index 000000000..5c647d667 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/entry/service/PrereservationSectionService.java @@ -0,0 +1,85 @@ +package com.back.b2st.domain.prereservation.entry.service; + +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.performanceschedule.entity.BookingType; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.entry.dto.response.PrereservationSectionRes; +import com.back.b2st.domain.prereservation.entry.entity.Prereservation; +import com.back.b2st.domain.prereservation.entry.error.PrereservationErrorCode; +import com.back.b2st.domain.prereservation.entry.repository.PrereservationRepository; +import com.back.b2st.domain.prereservation.policy.service.PrereservationTimeTableService; +import com.back.b2st.domain.prereservation.policy.service.PrereservationSlotService; +import com.back.b2st.domain.venue.section.entity.Section; +import com.back.b2st.domain.venue.section.repository.SectionRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrereservationSectionService { + + private final PerformanceScheduleRepository performanceScheduleRepository; + private final SectionRepository sectionRepository; + private final PrereservationRepository prereservationRepository; + private final PrereservationSlotService prereservationSlotService; + private final PrereservationTimeTableService prereservationTimeTableService; + + @Value("${prereservation.slot.strict:true}") + private boolean slotStrict = true; + + @Transactional + public List getSections(Long scheduleId, Long memberId) { + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SCHEDULE_NOT_FOUND)); + + if (schedule.getBookingType() != BookingType.PRERESERVE) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TYPE_NOT_SUPPORTED); + } + + Long venueId = schedule.getPerformance().getVenue().getVenueId(); + List
sections = sectionRepository.findByVenueId(venueId) + .stream() + .sorted(Comparator.comparing(Section::getId)) + .toList(); + + Set appliedSectionIds = prereservationRepository + .findAllByPerformanceScheduleIdAndMemberIdOrderByCreatedAtDesc(scheduleId, memberId) + .stream() + .map(Prereservation::getSectionId) + .collect(Collectors.toSet()); + + if (slotStrict) { + prereservationTimeTableService.ensureDefaultTimeTablesIfMissing(scheduleId); + } + + return sections.stream() + .map(section -> { + var slot = slotStrict + ? prereservationSlotService.calculateSlotOrThrow(schedule, section) + : new PrereservationSlotService.Slot( + schedule.getBookingOpenAt(), + schedule.getBookingCloseAt() != null + ? schedule.getBookingCloseAt() + : schedule.getBookingOpenAt().plusDays(30) + ); + return new PrereservationSectionRes( + section.getId(), + section.getSectionName(), + slot.startAt(), + slot.endAt(), + appliedSectionIds.contains(section.getId()) + ); + }) + .toList(); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/controller/PrereservationTimeTableController.java b/src/main/java/com/back/b2st/domain/prereservation/policy/controller/PrereservationTimeTableController.java new file mode 100644 index 000000000..eb251e6fe --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/controller/PrereservationTimeTableController.java @@ -0,0 +1,62 @@ +package com.back.b2st.domain.prereservation.policy.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.prereservation.policy.dto.request.PrereservationTimeTableUpsertListReq; +import com.back.b2st.domain.prereservation.policy.dto.response.PrereservationTimeTableRes; +import com.back.b2st.domain.prereservation.policy.service.PrereservationTimeTableService; +import com.back.b2st.global.common.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/prereservations/schedules/{scheduleId}/timetables") +@Tag(name = "신청 예매(관리자)", description = "신청 예매 회차별 구역 활성 시간대(타임테이블) 관리 API") +public class PrereservationTimeTableController { + + private final PrereservationTimeTableService prereservationTimeTableService; + + @GetMapping + @Operation( + summary = "회차 타임테이블 조회", + description = "신청 예매 회차의 구역별 예매 가능 시간대(타임테이블)를 조회합니다." + ) + public BaseResponse> getTimeTables( + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId + ) { + var timeTables = prereservationTimeTableService.getTimeTables(scheduleId).stream() + .map(PrereservationTimeTableRes::from) + .toList(); + return BaseResponse.success(timeTables); + } + + @PutMapping + @Operation( + summary = "회차 타임테이블 일괄 등록/수정", + description = """ + 신청 예매 회차의 구역별 예매 가능 시간대(타임테이블)를 일괄 등록/수정합니다. + - (scheduleId, sectionId) 기준으로 upsert 처리됩니다. + """ + ) + public BaseResponse upsert( + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + @Valid @RequestBody PrereservationTimeTableUpsertListReq request + ) { + prereservationTimeTableService.upsert(scheduleId, request.items()); + return BaseResponse.created(null); + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/dto/request/PrereservationTimeTableUpsertListReq.java b/src/main/java/com/back/b2st/domain/prereservation/policy/dto/request/PrereservationTimeTableUpsertListReq.java new file mode 100644 index 000000000..82156ba2e --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/dto/request/PrereservationTimeTableUpsertListReq.java @@ -0,0 +1,12 @@ +package com.back.b2st.domain.prereservation.policy.dto.request; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; + +public record PrereservationTimeTableUpsertListReq( + @NotEmpty @Valid List items +) { +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/dto/request/PrereservationTimeTableUpsertReq.java b/src/main/java/com/back/b2st/domain/prereservation/policy/dto/request/PrereservationTimeTableUpsertReq.java new file mode 100644 index 000000000..1b606af7a --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/dto/request/PrereservationTimeTableUpsertReq.java @@ -0,0 +1,13 @@ +package com.back.b2st.domain.prereservation.policy.dto.request; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotNull; + +public record PrereservationTimeTableUpsertReq( + @NotNull Long sectionId, + @NotNull LocalDateTime bookingStartAt, + @NotNull LocalDateTime bookingEndAt +) { +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/dto/response/PrereservationTimeTableRes.java b/src/main/java/com/back/b2st/domain/prereservation/policy/dto/response/PrereservationTimeTableRes.java new file mode 100644 index 000000000..473712f24 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/dto/response/PrereservationTimeTableRes.java @@ -0,0 +1,22 @@ +package com.back.b2st.domain.prereservation.policy.dto.response; + +import java.time.LocalDateTime; + +import com.back.b2st.domain.prereservation.policy.entity.PrereservationTimeTable; + +public record PrereservationTimeTableRes( + Long scheduleId, + Long sectionId, + LocalDateTime bookingStartAt, + LocalDateTime bookingEndAt +) { + public static PrereservationTimeTableRes from(PrereservationTimeTable timeTable) { + return new PrereservationTimeTableRes( + timeTable.getPerformanceScheduleId(), + timeTable.getSectionId(), + timeTable.getBookingStartAt(), + timeTable.getBookingEndAt() + ); + } +} + diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/entity/PrereservationTimeTable.java b/src/main/java/com/back/b2st/domain/prereservation/policy/entity/PrereservationTimeTable.java new file mode 100644 index 000000000..b5e73a9bb --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/entity/PrereservationTimeTable.java @@ -0,0 +1,84 @@ +package com.back.b2st.domain.prereservation.policy.entity; + +import java.time.LocalDateTime; + +import com.back.b2st.global.jpa.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) + @Table( + name = "prereservation_time_tables", + indexes = { + @Index( + name = "idx_prereservation_time_tables_schedule", + columnList = "performance_schedule_id" + ), + @Index( + name = "idx_prereservation_time_tables_schedule_section", + columnList = "performance_schedule_id, section_id" + ) + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_prereservation_time_tables_schedule_section", + columnNames = {"performance_schedule_id", "section_id"} + ) + } +) +@SequenceGenerator( + name = "prereservation_time_table_id_gen", + sequenceName = "prereservation_time_table_seq", + allocationSize = 50 +) +public class PrereservationTimeTable extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "prereservation_time_table_id_gen") + @Column(name = "prereservation_time_table_id") + private Long id; + + @Column(name = "performance_schedule_id", nullable = false) + private Long performanceScheduleId; + + @Column(name = "section_id", nullable = false) + private Long sectionId; + + @Column(name = "booking_start_at", nullable = false) + private LocalDateTime bookingStartAt; + + @Column(name = "booking_end_at", nullable = false) + private LocalDateTime bookingEndAt; + + @Builder + public PrereservationTimeTable( + Long performanceScheduleId, + Long sectionId, + LocalDateTime bookingStartAt, + LocalDateTime bookingEndAt + ) { + this.performanceScheduleId = performanceScheduleId; + this.sectionId = sectionId; + this.bookingStartAt = bookingStartAt; + this.bookingEndAt = bookingEndAt; + } + + public void updateBookingTime(LocalDateTime bookingStartAt, LocalDateTime bookingEndAt) { + this.bookingStartAt = bookingStartAt; + this.bookingEndAt = bookingEndAt; + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/repository/PrereservationTimeTableRepository.java b/src/main/java/com/back/b2st/domain/prereservation/policy/repository/PrereservationTimeTableRepository.java new file mode 100644 index 000000000..b089a2c66 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/repository/PrereservationTimeTableRepository.java @@ -0,0 +1,30 @@ +package com.back.b2st.domain.prereservation.policy.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.back.b2st.domain.prereservation.policy.entity.PrereservationTimeTable; + +public interface PrereservationTimeTableRepository extends JpaRepository { + + Optional findByPerformanceScheduleIdAndSectionId( + Long performanceScheduleId, + Long sectionId + ); + + /** + * (performanceScheduleId, sectionId) 조합이 중복으로 존재하더라도 단건 조회 시 예외가 나지 않도록 Top1로 조회합니다. + */ + Optional findTopByPerformanceScheduleIdAndSectionIdOrderByIdDesc( + Long performanceScheduleId, + Long sectionId + ); + + List findAllByPerformanceScheduleIdOrderByBookingStartAtAscSectionIdAsc( + Long performanceScheduleId + ); + + void deleteAllByPerformanceScheduleIdIn(List performanceScheduleIds); +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/service/PrereservationSlotService.java b/src/main/java/com/back/b2st/domain/prereservation/policy/service/PrereservationSlotService.java new file mode 100644 index 000000000..b25a323c9 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/service/PrereservationSlotService.java @@ -0,0 +1,42 @@ +package com.back.b2st.domain.prereservation.policy.service; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; + +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.prereservation.entry.error.PrereservationErrorCode; +import com.back.b2st.domain.prereservation.policy.repository.PrereservationTimeTableRepository; +import com.back.b2st.domain.venue.section.entity.Section; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrereservationSlotService { + + private final PrereservationTimeTableRepository prereservationTimeTableRepository; + + public Slot calculateSlotOrThrow(PerformanceSchedule schedule, Section section) { + validateBookingTimeConfigured(schedule); + + var timeTable = prereservationTimeTableRepository + .findTopByPerformanceScheduleIdAndSectionIdOrderByIdDesc( + schedule.getPerformanceScheduleId(), + section.getId() + ) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.TIME_TABLE_NOT_CONFIGURED)); + + return new Slot(timeTable.getBookingStartAt(), timeTable.getBookingEndAt()); + } + + private void validateBookingTimeConfigured(PerformanceSchedule schedule) { + if (schedule.getBookingOpenAt() == null) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TIME_NOT_CONFIGURED); + } + } + + public record Slot(LocalDateTime startAt, LocalDateTime endAt) { + } +} diff --git a/src/main/java/com/back/b2st/domain/prereservation/policy/service/PrereservationTimeTableService.java b/src/main/java/com/back/b2st/domain/prereservation/policy/service/PrereservationTimeTableService.java new file mode 100644 index 000000000..1227f9c1d --- /dev/null +++ b/src/main/java/com/back/b2st/domain/prereservation/policy/service/PrereservationTimeTableService.java @@ -0,0 +1,139 @@ +package com.back.b2st.domain.prereservation.policy.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.performanceschedule.entity.BookingType; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.entry.error.PrereservationErrorCode; +import com.back.b2st.domain.prereservation.policy.dto.request.PrereservationTimeTableUpsertReq; +import com.back.b2st.domain.prereservation.policy.entity.PrereservationTimeTable; +import com.back.b2st.domain.prereservation.policy.repository.PrereservationTimeTableRepository; +import com.back.b2st.domain.venue.section.entity.Section; +import com.back.b2st.domain.venue.section.repository.SectionRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrereservationTimeTableService { + + private final PerformanceScheduleRepository performanceScheduleRepository; + private final SectionRepository sectionRepository; + private final PrereservationTimeTableRepository prereservationTimeTableRepository; + + @Transactional(readOnly = true) + public List getTimeTables(Long scheduleId) { + validatePrereserveScheduleOrThrow(scheduleId); + return prereservationTimeTableRepository + .findAllByPerformanceScheduleIdOrderByBookingStartAtAscSectionIdAsc(scheduleId); + } + + @Transactional + public void upsert(Long scheduleId, List items) { + PerformanceSchedule schedule = validatePrereserveScheduleOrThrow(scheduleId); + + for (PrereservationTimeTableUpsertReq item : items) { + Section section = sectionRepository.findById(item.sectionId()) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SECTION_NOT_FOUND)); + + Long venueId = schedule.getPerformance().getVenue().getVenueId(); + if (!section.getVenueId().equals(venueId)) { + throw new BusinessException(PrereservationErrorCode.SECTION_NOT_IN_VENUE); + } + + validateTimeRangeOrThrow(item.bookingStartAt(), item.bookingEndAt()); + validateWithinScheduleOrThrow(schedule, item.bookingStartAt(), item.bookingEndAt()); + + PrereservationTimeTable timeTable = prereservationTimeTableRepository + .findByPerformanceScheduleIdAndSectionId(scheduleId, item.sectionId()) + .orElseGet(() -> PrereservationTimeTable.builder() + .performanceScheduleId(scheduleId) + .sectionId(item.sectionId()) + .bookingStartAt(item.bookingStartAt()) + .bookingEndAt(item.bookingEndAt()) + .build()); + + timeTable.updateBookingTime(item.bookingStartAt(), item.bookingEndAt()); + prereservationTimeTableRepository.save(timeTable); + } + } + + @Transactional + public void ensureDefaultTimeTablesIfMissing(Long scheduleId) { + PerformanceSchedule schedule = validatePrereserveScheduleOrThrow(scheduleId); + + Long venueId = schedule.getPerformance().getVenue().getVenueId(); + List
sections = sectionRepository.findByVenueId(venueId).stream() + .sorted(java.util.Comparator.comparing(Section::getSectionName)) + .toList(); + + if (sections.isEmpty()) { + throw new BusinessException(PrereservationErrorCode.SECTION_NOT_FOUND); + } + + LocalDateTime bookingOpenAt = schedule.getBookingOpenAt(); + + for (int idx = 0; idx < sections.size(); idx++) { + Section section = sections.get(idx); + + if (prereservationTimeTableRepository.findByPerformanceScheduleIdAndSectionId(scheduleId, section.getId()) + .isPresent()) { + continue; + } + + LocalDateTime startAt = bookingOpenAt.plusHours(idx); + LocalDateTime endAt = startAt.plusHours(1).minusSeconds(1); + + validateTimeRangeOrThrow(startAt, endAt); + validateWithinScheduleOrThrow(schedule, startAt, endAt); + + try { + prereservationTimeTableRepository.save(PrereservationTimeTable.builder() + .performanceScheduleId(scheduleId) + .sectionId(section.getId()) + .bookingStartAt(startAt) + .bookingEndAt(endAt) + .build()); + } catch (DataIntegrityViolationException ignored) { + } + } + } + + private PerformanceSchedule validatePrereserveScheduleOrThrow(Long scheduleId) { + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(PrereservationErrorCode.SCHEDULE_NOT_FOUND)); + + if (schedule.getBookingType() != BookingType.PRERESERVE) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TYPE_NOT_SUPPORTED); + } + + if (schedule.getBookingOpenAt() == null) { + throw new BusinessException(PrereservationErrorCode.BOOKING_TIME_NOT_CONFIGURED); + } + + return schedule; + } + + private void validateTimeRangeOrThrow(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt.isAfter(endAt) || startAt.isEqual(endAt)) { + throw new BusinessException(PrereservationErrorCode.TIME_TABLE_NOT_CONFIGURED); + } + } + + private void validateWithinScheduleOrThrow(PerformanceSchedule schedule, LocalDateTime startAt, LocalDateTime endAt) { + if (startAt.isBefore(schedule.getBookingOpenAt())) { + throw new BusinessException(PrereservationErrorCode.TIME_TABLE_NOT_CONFIGURED); + } + + if (schedule.getBookingCloseAt() != null && endAt.isAfter(schedule.getBookingCloseAt())) { + throw new BusinessException(PrereservationErrorCode.TIME_TABLE_NOT_CONFIGURED); + } + } +} diff --git a/src/main/java/com/back/b2st/domain/queue/controller/AdminQueueController.java b/src/main/java/com/back/b2st/domain/queue/controller/AdminQueueController.java new file mode 100644 index 000000000..9ca13c2cd --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/controller/AdminQueueController.java @@ -0,0 +1,175 @@ +package com.back.b2st.domain.queue.controller; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.queue.dto.request.CreateQueueReq; +import com.back.b2st.domain.queue.dto.request.UpdateQueueReq; +import com.back.b2st.domain.queue.dto.response.QueueRes; +import com.back.b2st.domain.queue.dto.response.QueueStatisticsRes; +import com.back.b2st.domain.queue.service.QueueManagementService; +import com.back.b2st.domain.queue.service.QueueService; +import com.back.b2st.global.common.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Admin Queue Controller + * + * 대기열 관리자용 REST API + * - 관리자 권한(ROLE_ADMIN) 필요 + */ +@RestController +@RequestMapping("/api/admin/queues") +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +@Tag(name = "AdminQueueController", description = "대기열 관리 API (관리자 전용)") +@SecurityRequirement(name = "BearerAuth") +@PreAuthorize("hasRole('ADMIN')") +public class AdminQueueController { + + private final QueueService queueService; + private final QueueManagementService queueManagementService; + + /** + * 대기열 생성 + * POST /api/admin/queues + */ + @Operation( + summary = "대기열 생성", + description = "새로운 대기열을 생성합니다. 공연당 큐는 1개만 존재합니다 (UNIQUE 제약: performance_id)." + ) + @PostMapping + public BaseResponse createQueue( + @Valid @RequestBody CreateQueueReq request + ) { + log.info("Admin creating queue - performanceId: {}, queueType: {}", + request.performanceId(), request.queueType()); + QueueRes response = queueManagementService.createQueue(request); + return BaseResponse.created(response); + } + + /** + * 대기열 목록 조회 (필터링) + * GET /api/admin/queues - 전체 목록 조회 + * GET /api/admin/queues?performanceId=1 - 공연별 조회 + * GET /api/admin/queues?queueType=BOOKING_ORDER - 타입별 조회 + */ + @Operation( + summary = "대기열 목록 조회", + description = "전체 대기열 목록을 조회합니다. performanceId 또는 queueType으로 필터링할 수 있습니다." + ) + @GetMapping + public BaseResponse> getQueues( + @Parameter(description = "공연 ID (선택)", example = "1") + @RequestParam(required = false) Long performanceId, + @Parameter(description = "대기열 타입 (선택)", example = "BOOKING_ORDER") + @RequestParam(required = false) String queueType + ) { + if (performanceId != null) { + log.debug("Admin getting queues by performance - performanceId: {}", performanceId); + return BaseResponse.success(queueManagementService.getQueuesByPerformance(performanceId)); + } + + if (queueType != null && !queueType.isBlank()) { + log.debug("Admin getting queues by type - queueType: {}", queueType); + return BaseResponse.success(queueManagementService.getQueuesByType(queueType)); + } + + log.debug("Admin getting all queues"); + return BaseResponse.success(queueManagementService.getAllQueues()); + } + + /** + * 대기열 상세 조회 + * GET /api/admin/queues/{queueId} + */ + @Operation( + summary = "대기열 상세 조회", + description = "대기열의 상세 정보를 조회합니다. Redis 기반 실시간 대기/입장 가능 인원 수가 포함됩니다." + ) + @GetMapping("/{queueId}") + public BaseResponse getQueue( + @Parameter(description = "대기열 ID", example = "1") + @PathVariable Long queueId + ) { + log.debug("Admin getting queue - queueId: {}", queueId); + QueueRes response = queueManagementService.getQueue(queueId); + return BaseResponse.success(response); + } + + /** + * 대기열 설정 수정 + * PATCH /api/admin/queues/{queueId} + */ + @Operation( + summary = "대기열 설정 수정", + description = "대기열 설정(maxActiveUsers, entryTtlMinutes)을 부분 수정합니다." + ) + @PatchMapping("/{queueId}") + public BaseResponse updateQueue( + @Parameter(description = "대기열 ID", example = "1") + @PathVariable Long queueId, + @Valid @RequestBody UpdateQueueReq request + ) { + log.info("Admin updating queue - queueId: {}, maxActiveUsers: {}, entryTtlMinutes: {}", + queueId, request.maxActiveUsers(), request.entryTtlMinutes()); + QueueRes response = queueManagementService.updateQueue(queueId, request); + return BaseResponse.success(response); + } + + /** + * 대기열 삭제 + * DELETE /api/admin/queues/{queueId} + */ + @Operation( + summary = "대기열 삭제", + description = "대기열을 삭제합니다. Redis 데이터도 함께 정리됩니다." + ) + @DeleteMapping("/{queueId}") + public BaseResponse deleteQueue( + @Parameter(description = "대기열 ID", example = "1") + @PathVariable Long queueId + ) { + log.info("Admin deleting queue - queueId: {}", queueId); + queueManagementService.deleteQueue(queueId); + return BaseResponse.success(null); + } + + /** + * 대기열 통계 조회 + * GET /api/admin/queues/{queueId}/statistics + */ + @Operation( + summary = "대기열 통계 조회", + description = "대기열의 전체 통계 정보를 조회합니다. Redis 기반 실시간 카운트 + DB 기반 상태별 통계가 포함됩니다." + ) + @GetMapping("/{queueId}/statistics") + public BaseResponse getQueueStatistics( + @Parameter(description = "대기열 ID", example = "1") + @PathVariable Long queueId + ) { + log.debug("Admin getting queue statistics - queueId: {}", queueId); + QueueStatisticsRes response = queueService.getQueueStatisticsForAdmin(queueId); + return BaseResponse.success(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/queue/controller/QueueController.java b/src/main/java/com/back/b2st/domain/queue/controller/QueueController.java new file mode 100644 index 000000000..20998d416 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/controller/QueueController.java @@ -0,0 +1,162 @@ +package com.back.b2st.domain.queue.controller; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.queue.dto.response.QueuePositionRes; +import com.back.b2st.domain.queue.dto.response.StartBookingRes; +import com.back.b2st.domain.queue.service.QueueService; +import com.back.b2st.global.common.BaseResponse; +import com.back.b2st.global.util.SecurityUtils; +import com.back.b2st.security.UserPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Queue 관련 API 컨트롤러 + * + * 사용자의 대기열 진입, 위치 조회, 권한 소진 등을 담당합니다. + * 모든 엔드포인트는 로그인이 필요합니다. + */ +@RestController +@RequestMapping("/api/queues") +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +@Tag(name = "QueueController", description = "대기열 API (로그인 필수)") +@SecurityRequirement(name = "BearerAuth") +public class QueueController { + + private final QueueService queueService; + + /** + * 예매 확정 후 권한 소진 + * + * 사용자가 예매(좌석/결제)를 모두 완료한 후 호출합니다. + * ENTERABLE 상태의 권한을 COMPLETED로 변경하여 소진 처리합니다. + * + * @param queueId 대기열 ID + * @param principal 로그인한 사용자 정보 + * @return 성공 응답 + */ + @Operation( + summary = "예매 확정 후 권한 소진", + description = "예매(좌석 선택, 결제)를 모두 완료한 사용자의 ENTERABLE 권한을 COMPLETED 상태로 변경하여 소진 처리합니다. " + + "이후 해당 사용자는 동일 공연 대기열에 다시 진입 가능합니다." + ) + @PostMapping("/{queueId}/complete") + public BaseResponse completeEntry( + @Parameter(description = "대기열 ID", example = "1") + @PathVariable @Positive Long queueId, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long userId = SecurityUtils.requireUserId(principal); + log.info("User completing booking (권한 소진) - queueId: {}, userId: {}", queueId, userId); + queueService.completeEntry(queueId, userId); + return BaseResponse.success(null); + } + + /** + * 대기열 나가기 + * + * 사용자가 대기 중이거나 입장 가능한 상태에서 대기열을 포기할 때 호출합니다. + * WAITING 또는 ENTERABLE 상태를 EXPIRED로 변경하고 Redis에서 제거합니다. + * + * @param queueId 대기열 ID + * @param principal 로그인한 사용자 정보 + * @return 성공 응답 + */ + @Operation( + summary = "대기열 나가기", + description = "대기 중(WAITING) 또는 입장 가능(ENTERABLE) 상태를 EXPIRED로 변경하고 Redis에서 제거합니다." + ) + @DeleteMapping("/{queueId}/exit") + public BaseResponse exitQueue( + @Parameter(description = "대기열 ID", example = "1") + @PathVariable @Positive Long queueId, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long userId = SecurityUtils.requireUserId(principal); + log.info("User exiting queue - queueId: {}, userId: {}", queueId, userId); + queueService.exitQueue(queueId, userId); + return BaseResponse.success(null); + } + + /** + * 내 대기 위치 조회 + * + * 현재 사용자의 대기열 내 위치, 상태, 앞의 인원 수를 조회합니다. + * Redis 실시간 데이터를 기반으로 합니다. + * + * @param queueId 대기열 ID + * @param principal 로그인한 사용자 정보 + * @return 대기 위치 정보 (상태, 랭크, 앞 인원 수) + */ + @Operation( + summary = "대기 위치 조회", + description = "현재 사용자의 대기열 상태(WAITING/ENTERABLE/EXPIRED/COMPLETED), 랭크, 앞의 인원 수를 조회합니다." + ) + @GetMapping("/{queueId}/position") + public BaseResponse getMyPosition( + @Parameter(description = "대기열 ID", example = "1") + @PathVariable @Positive Long queueId, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long userId = SecurityUtils.requireUserId(principal); + log.debug("User checking queue position - queueId: {}, userId: {}", queueId, userId); + QueuePositionRes response = queueService.getMyPosition(queueId, userId); + return BaseResponse.success(response); + } + + /** + * 예매 시작: scheduleId로 대기열 자동 생성 및 입장 (Idempotent) + * + * 공연 상세 페이지에서 예매하기 버튼 클릭 시 호출합니다. + * + * **Idempotent 동작:** + * - 이미 WAITING/ENTERABLE 상태면 409 대신 현재 상태 반환 (HTTP 201) + * - 프론트는 응답의 entry.status로 화면 렌더링 가능 + * - 재시도/새로고침이 자주 일어나도 안전하게 처리됨 + * + * **처리 과정:** + * 1. scheduleId → performanceId 변환 + * 2. 공연 단위 큐 자동 생성/조회 (멱등성 보장, 레이스 컨디션 방어) + * 3. 이미 WAITING/ENTERABLE 상태면 현재 상태 반환 + * 4. 아니면 새로운 입장 처리 + * + * @param scheduleId 공연 회차 ID (프론트 UX용 진입 정보) + * @param principal 로그인한 사용자 정보 + * @return 예매 시작 응답 (queueId, performanceId, scheduleId, entry 포함) + */ + @Operation( + summary = "예매 시작 (Idempotent)", + description = "공연 회차 ID로 대기열을 자동 생성하고 입장합니다. " + + "대기열이 없으면 자동으로 생성됩니다. " + + "공연 단위로 큐가 관리됩니다. " + + "이미 대기 중이거나 입장 가능한 상태면 현재 상태를 반환합니다 (409 대신 201)." + ) + @PostMapping("/start-booking/{scheduleId}") + public BaseResponse startBooking( + @Parameter(description = "공연 회차 ID (프론트 UX용 진입 정보)", example = "1") + @PathVariable @Positive Long scheduleId, + @AuthenticationPrincipal UserPrincipal principal + ) { + Long userId = SecurityUtils.requireUserId(principal); + log.info("User starting booking - scheduleId: {}, userId: {}", scheduleId, userId); + StartBookingRes response = queueService.startBooking(scheduleId, userId); + return BaseResponse.created(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/queue/dto/MoveResult.java b/src/main/java/com/back/b2st/domain/queue/dto/MoveResult.java new file mode 100644 index 000000000..364921599 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/dto/MoveResult.java @@ -0,0 +1,27 @@ +package com.back.b2st.domain.queue.dto; + +/** + * WAITING → ENTERABLE 이동 결과 + * + * Lua 스크립트 반환값과 매핑: + * - 1L → MOVED (성공) + * - 0L → SKIPPED (이미 처리됨 또는 WAITING에 없음) + * - -1L → REJECTED_FULL (maxActiveUsers 상한 초과로 거부) + */ +public enum MoveResult { + /** + * WAITING → ENTERABLE 이동 성공 + */ + MOVED, + + /** + * 이미 처리됨 또는 WAITING에 없음 (정상 스킵) + */ + SKIPPED, + + /** + * maxActiveUsers 상한 초과로 승격 거부 (정상, 다음 스케줄에 재시도) + */ + REJECTED_FULL +} + diff --git a/src/main/java/com/back/b2st/domain/queue/dto/QueueDefaultPolicy.java b/src/main/java/com/back/b2st/domain/queue/dto/QueueDefaultPolicy.java new file mode 100644 index 000000000..7ff131e54 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/dto/QueueDefaultPolicy.java @@ -0,0 +1,24 @@ +package com.back.b2st.domain.queue.dto; + +import com.back.b2st.domain.queue.entity.QueueType; + +/** + * 대기열 자동 생성 시 사용하는 기본 정책 + */ +public record QueueDefaultPolicy( + QueueType queueType, + Integer maxActiveUsers, + Integer entryTtlMinutes +) { + /** + * 예매용 기본 정책 + */ + public static QueueDefaultPolicy defaultBooking() { + return new QueueDefaultPolicy( + QueueType.BOOKING_ORDER, + 200, + 10 + ); + } +} + diff --git a/src/main/java/com/back/b2st/domain/queue/dto/request/CreateQueueReq.java b/src/main/java/com/back/b2st/domain/queue/dto/request/CreateQueueReq.java index 69aa99ed6..519dade3c 100644 --- a/src/main/java/com/back/b2st/domain/queue/dto/request/CreateQueueReq.java +++ b/src/main/java/com/back/b2st/domain/queue/dto/request/CreateQueueReq.java @@ -8,8 +8,8 @@ * 대기열 생성 요청 DTO */ public record CreateQueueReq( - @NotNull(message = "회차 ID는 필수입니다.") - Long scheduleId, + @NotNull(message = "공연 ID는 필수입니다.") + Long performanceId, @NotBlank(message = "대기열 타입은 필수입니다.") String queueType, diff --git a/src/main/java/com/back/b2st/domain/queue/dto/response/QueueEntryRes.java b/src/main/java/com/back/b2st/domain/queue/dto/response/QueueEntryRes.java index 239b5e4f0..e1dee1c62 100644 --- a/src/main/java/com/back/b2st/domain/queue/dto/response/QueueEntryRes.java +++ b/src/main/java/com/back/b2st/domain/queue/dto/response/QueueEntryRes.java @@ -9,44 +9,52 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public record QueueEntryRes( Long queueId, + Long performanceId, + Long scheduleId, // 프론트 UX용 진입 정보 Long userId, String status, // WAITING(Redis), ENTERABLE, EXPIRED, COMPLETED - Integer myRank, // 내 순번 (1부터 시작) - Integer waitingAhead // 내 앞에 대기 중인 사람 수 + Integer aheadCount, // 내 앞에 대기 중인 사람 수 + Integer myRank // 내 순번 (1부터 시작) ) { /** * WAITING 상태 응답 (Redis에만 존재) */ - public static QueueEntryRes waiting(Long queueId, Long userId, Integer myRank, Integer waitingAhead) { + public static QueueEntryRes waiting(Long queueId, Long performanceId, Long scheduleId, Long userId, Integer aheadCount, Integer myRank) { return new QueueEntryRes( queueId, + performanceId, + scheduleId, userId, "WAITING", - myRank, - waitingAhead + aheadCount, + myRank ); } /** * QueueEntry → Response 변환 (DB 저장된 상태) */ - public static QueueEntryRes of(QueueEntry entry, Integer myRank, Integer waitingAhead) { + public static QueueEntryRes of(QueueEntry entry, Long performanceId, Long scheduleId, Integer aheadCount, Integer myRank) { return new QueueEntryRes( entry.getQueueId(), + performanceId, + scheduleId, entry.getUserId(), entry.getStatus().name(), - myRank, - waitingAhead + aheadCount, + myRank ); } /** * 간단한 응답 (순번 정보 없음) */ - public static QueueEntryRes simple(QueueEntry entry) { + public static QueueEntryRes simple(QueueEntry entry, Long performanceId, Long scheduleId) { return new QueueEntryRes( entry.getQueueId(), + performanceId, + scheduleId, entry.getUserId(), entry.getStatus().name(), null, diff --git a/src/main/java/com/back/b2st/domain/queue/dto/response/QueuePositionRes.java b/src/main/java/com/back/b2st/domain/queue/dto/response/QueuePositionRes.java new file mode 100644 index 000000000..3f9ca9858 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/dto/response/QueuePositionRes.java @@ -0,0 +1,102 @@ +package com.back.b2st.domain.queue.dto.response; + +import com.back.b2st.domain.queue.entity.QueueEntry; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 대기열 위치 조회 응답 DTO (사용자용) + * + * 사용자가 자신의 대기 위치 정보만 조회할 수 있습니다. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record QueuePositionRes( + Long queueId, + Long userId, + String status, // WAITING, ENTERABLE, EXPIRED, COMPLETED, NOT_IN_QUEUE + Integer aheadCount, // 내 앞에 있는 사람 수 (WAITING 상태일 때만) + Integer myRank // 내 순번 (WAITING 상태일 때만) +) { + /** + * WAITING 상태 응답 + */ + public static QueuePositionRes waiting( + Long queueId, + Long userId, + Integer aheadCount, + Integer myRank + ) { + return new QueuePositionRes( + queueId, + userId, + "WAITING", + aheadCount, + myRank + ); + } + + /** + * ENTERABLE 상태 응답 + */ + public static QueuePositionRes enterable(Long queueId, Long userId) { + return new QueuePositionRes( + queueId, + userId, + "ENTERABLE", + null, + null + ); + } + + /** + * EXPIRED 상태 응답 + */ + public static QueuePositionRes expired(Long queueId, Long userId) { + return new QueuePositionRes( + queueId, + userId, + "EXPIRED", + null, + null + ); + } + + /** + * COMPLETED 상태 응답 + */ + public static QueuePositionRes completed(Long queueId, Long userId) { + return new QueuePositionRes( + queueId, + userId, + "COMPLETED", + null, + null + ); + } + + /** + * 대기열에 등록되지 않은 경우 + */ + public static QueuePositionRes notInQueue(Long queueId, Long userId) { + return new QueuePositionRes( + queueId, + userId, + "NOT_IN_QUEUE", + null, + null + ); + } + + /** + * QueueEntry로부터 생성 (DB 저장된 상태) + */ + public static QueuePositionRes fromEntry(QueueEntry entry) { + return new QueuePositionRes( + entry.getQueueId(), + entry.getUserId(), + entry.getStatus().name(), + null, + null + ); + } +} + diff --git a/src/main/java/com/back/b2st/domain/queue/dto/response/QueueRes.java b/src/main/java/com/back/b2st/domain/queue/dto/response/QueueRes.java index 28dc73215..c7a8e5700 100644 --- a/src/main/java/com/back/b2st/domain/queue/dto/response/QueueRes.java +++ b/src/main/java/com/back/b2st/domain/queue/dto/response/QueueRes.java @@ -9,7 +9,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public record QueueRes( Long queueId, - Long scheduleId, + Long performanceId, String queueType, Integer maxActiveUsers, Integer entryTtlMinutes, @@ -23,7 +23,7 @@ public record QueueRes( public static QueueRes from(Queue queue) { return new QueueRes( queue.getId(), - queue.getScheduleId(), + queue.getPerformanceId(), queue.getQueueType().name(), queue.getMaxActiveUsers(), queue.getEntryTtlMinutes(), @@ -38,7 +38,7 @@ public static QueueRes from(Queue queue) { public static QueueRes of(Queue queue, Integer currentWaiting, Integer currentEnterable) { return new QueueRes( queue.getId(), - queue.getScheduleId(), + queue.getPerformanceId(), queue.getQueueType().name(), queue.getMaxActiveUsers(), queue.getEntryTtlMinutes(), diff --git a/src/main/java/com/back/b2st/domain/queue/dto/response/QueueStatisticsRes.java b/src/main/java/com/back/b2st/domain/queue/dto/response/QueueStatisticsRes.java new file mode 100644 index 000000000..782137517 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/dto/response/QueueStatisticsRes.java @@ -0,0 +1,59 @@ +package com.back.b2st.domain.queue.dto.response; + +import java.util.List; + +import com.back.b2st.domain.queue.dto.QueueEntryStatusCount; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 대기열 통계 조회 응답 DTO (관리자용) + * + * 전체 통계 정보를 포함합니다. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record QueueStatisticsRes( + Long queueId, + Integer totalWaiting, // Redis WAITING 카운트 + Integer totalEnterable, // Redis ENTERABLE 카운트 + Integer maxActiveUsers, // 최대 활성 사용자 수 + List statusCounts // DB 상태별 통계 +) { + /** + * 상태별 카운트 + */ + public record StatusCount( + String status, // WAITING, ENTERABLE, EXPIRED, COMPLETED + Long count + ) { + public static StatusCount from(QueueEntryStatusCount count) { + return new StatusCount( + count.getStatus().name(), + count.getCount() + ); + } + } + + /** + * 통계 응답 생성 + */ + public static QueueStatisticsRes of( + Long queueId, + Integer totalWaiting, + Integer totalEnterable, + Integer maxActiveUsers, + List statusCounts + ) { + List counts = statusCounts.stream() + .map(StatusCount::from) + .toList(); + + return new QueueStatisticsRes( + queueId, + totalWaiting, + totalEnterable, + maxActiveUsers, + counts + ); + } +} + diff --git a/src/main/java/com/back/b2st/domain/queue/dto/response/QueueStatusRes.java b/src/main/java/com/back/b2st/domain/queue/dto/response/QueueStatusRes.java deleted file mode 100644 index 13ad8bc71..000000000 --- a/src/main/java/com/back/b2st/domain/queue/dto/response/QueueStatusRes.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.back.b2st.domain.queue.dto.response; - -import com.back.b2st.domain.queue.entity.QueueEntry; -import com.fasterxml.jackson.annotation.JsonInclude; - -/** - * 대기열 상태 조회 응답 DTO - */ -@JsonInclude(JsonInclude.Include.NON_NULL) -public record QueueStatusRes( - Long queueId, - Long userId, - String status, // WAITING(Redis), ENTERABLE, EXPIRED, COMPLETED - Integer myRank, - Integer waitingAhead, - Integer totalWaiting, - Integer maxActiveUsers -) { - - /** - * WAITING 상태 응답 (Redis에만 존재) - */ - public static QueueStatusRes waiting( - Long queueId, - Long userId, - Integer myRank, - Integer waitingAhead, - Integer totalWaiting - ) { - return new QueueStatusRes( - queueId, - userId, - "WAITING", - myRank, - waitingAhead, - totalWaiting, - null - ); - } - - /** - * ENTERABLE 상태 응답 (Redis에 존재) - */ - public static QueueStatusRes enterable(Long queueId, Long userId) { - return new QueueStatusRes( - queueId, - userId, - "ENTERABLE", - null, - null, - null, - null - ); - } - - /** - * QueueEntry로부터 생성 (DB 저장된 상태) - */ - public static QueueStatusRes fromEntry(QueueEntry entry) { - return new QueueStatusRes( - entry.getQueueId(), - entry.getUserId(), - entry.getStatus().name(), - null, - null, - null, - null - ); - } - - /** - * 대기열 통계 응답 - */ - public static QueueStatusRes statistics( - Long queueId, - Integer totalWaiting, - Integer totalEnterable, - Integer maxActiveUsers - ) { - return new QueueStatusRes( - queueId, - null, - null, - null, - null, - totalWaiting, - maxActiveUsers - ); - } -} - diff --git a/src/main/java/com/back/b2st/domain/queue/dto/response/StartBookingRes.java b/src/main/java/com/back/b2st/domain/queue/dto/response/StartBookingRes.java new file mode 100644 index 000000000..5d3f96bcb --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/dto/response/StartBookingRes.java @@ -0,0 +1,19 @@ +package com.back.b2st.domain.queue.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 예매 시작 응답 DTO + * + * 프론트 UX가 "상세에서 회차 선택 → 대기열 진입"이므로 scheduleId를 입력으로 받고, + * 서버 정책은 공연 단위 queueId 1개이므로 응답에 queueId, performanceId, scheduleId를 포함 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record StartBookingRes( + Long queueId, // 공연 단위 큐 ID + Long performanceId, // 권한 체크/좌석 API의 기준 + Long scheduleId, // 첫 진입 회차로 프론트가 세팅 + QueueEntryRes entry // 대기열 입장 상세 정보 +) { +} + diff --git a/src/main/java/com/back/b2st/domain/queue/entity/Queue.java b/src/main/java/com/back/b2st/domain/queue/entity/Queue.java index 11dde1da9..9522e99c1 100644 --- a/src/main/java/com/back/b2st/domain/queue/entity/Queue.java +++ b/src/main/java/com/back/b2st/domain/queue/entity/Queue.java @@ -27,17 +27,18 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table( - name = "queues", - uniqueConstraints = {@UniqueConstraint( - name = "uk_queue_schedule_type", - columnNames = {"schedule_id", "queue_type"} - ) - } + name = "queues", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_queue_performance", + columnNames = {"performance_id"} + ) + } ) @SequenceGenerator( - name = "queue_id_gen", - sequenceName = "queue_seq", - allocationSize = 50 + name = "queue_id_gen", + sequenceName = "queue_seq", + allocationSize = 50 ) @DynamicUpdate public class Queue extends BaseEntity { @@ -48,9 +49,9 @@ public class Queue extends BaseEntity { @Schema(description = "대기열 ID", example = "1") private Long id; - @Column(name = "schedule_id", nullable = false) - @Schema(description = "공연 회차 ID", example = "1") - private Long scheduleId; + @Column(name = "performance_id", nullable = false) + @Schema(description = "공연 ID", example = "1") + private Long performanceId; @Enumerated(EnumType.STRING) @Column(name = "queue_type", nullable = false, length = 20) @@ -67,12 +68,12 @@ public class Queue extends BaseEntity { @Builder public Queue( - Long scheduleId, - QueueType queueType, - Integer maxActiveUsers, - Integer entryTtlMinutes + Long performanceId, + QueueType queueType, + Integer maxActiveUsers, + Integer entryTtlMinutes ) { - this.scheduleId = scheduleId; + this.performanceId = performanceId; this.queueType = queueType; this.maxActiveUsers = maxActiveUsers; this.entryTtlMinutes = entryTtlMinutes; diff --git a/src/main/java/com/back/b2st/domain/queue/entity/QueueEntry.java b/src/main/java/com/back/b2st/domain/queue/entity/QueueEntry.java index fcdd236fb..33233e00b 100644 --- a/src/main/java/com/back/b2st/domain/queue/entity/QueueEntry.java +++ b/src/main/java/com/back/b2st/domain/queue/entity/QueueEntry.java @@ -24,38 +24,40 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; + /** - * 대기열 참가 엔티티 + * 대기열 입장 기록 엔티티 + * + * 인덱스 전략: + * - UNIQUE 제약이 자동으로 인덱스 생성 (uk_queue_user, uk_entry_token) + * - cleanup 쿼리용: (status, expires_at) + * - 통계 쿼리용: (queue_id, status) */ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table( - name = "queue_entries", - indexes = { - @Index(name = "idx_queue_entries_user_queue", columnList = "user_id, queue_id"), - @Index(name = "idx_queue_entries_queue_status", columnList = "queue_id, status"), - @Index(name = "idx_queue_entries_queue_status_expires", columnList = "queue_id, status, expires_at"), - @Index(name = "idx_queue_entries_token", columnList = "entry_token"), - @Index(name = "idx_queue_entries_status_expires", columnList = "status, expires_at"), - @Index(name = "idx_queue_entries_user_status", columnList = "user_id, status") - }, - uniqueConstraints = { - @UniqueConstraint( - name = "uk_queue_user", - columnNames = {"queue_id", "user_id"} - ), - @UniqueConstraint( - name = "uk_entry_token", - columnNames = {"entry_token"} - ) - } + name = "queue_entries", + indexes = { + // cleanup 쿼리용 + @Index(name = "idx_queue_entries_status_expires", + columnList = "status, expires_at"), + + // 통계 쿼리용 + @Index(name = "idx_queue_entries_queue_status", + columnList = "queue_id, status") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_queue_user", + columnNames = {"queue_id", "user_id"}), + @UniqueConstraint(name = "uk_entry_token", + columnNames = {"entry_token"}) + } ) - @SequenceGenerator( - name = "queue_entry_id_gen", - sequenceName = "queue_entry_seq", - allocationSize = 50 + name = "queue_entry_id_gen", + sequenceName = "queue_entry_seq", + allocationSize = 50 ) @DynamicUpdate public class QueueEntry extends BaseEntity { @@ -108,30 +110,32 @@ public void generateEntryToken() { @Builder public QueueEntry( - Long queueId, - Long userId, - LocalDateTime joinedAt, - LocalDateTime enterableAt, - LocalDateTime expiresAt + Long queueId, + Long userId, + UUID entryToken, + QueueEntryStatus status, + LocalDateTime joinedAt, + LocalDateTime enterableAt, + LocalDateTime expiresAt ) { this.queueId = queueId; this.userId = userId; + this.entryToken = entryToken; + this.status = (status != null) ? status : QueueEntryStatus.EXPIRED; this.joinedAt = joinedAt; this.enterableAt = enterableAt; this.expiresAt = expiresAt; - this.status = QueueEntryStatus.ENTERABLE; } - // ===== 상태 전이 메서드 ===== + // ===== 상태 전이 메서드 ===== - /** - * ENTERABLE 상태로 전환 - */ public void updateToEnterable( - LocalDateTime joinedAt, - LocalDateTime enterableAt, - LocalDateTime expiresAt + UUID newEntryToken, + LocalDateTime joinedAt, + LocalDateTime enterableAt, + LocalDateTime expiresAt ) { + this.entryToken = newEntryToken; this.status = QueueEntryStatus.ENTERABLE; this.joinedAt = joinedAt; this.enterableAt = enterableAt; @@ -139,19 +143,12 @@ public void updateToEnterable( this.completedAt = null; } - /** - * EXPIRED 상태로 전환 - */ public void updateToExpired() { this.status = QueueEntryStatus.EXPIRED; } - /** - * COMPLETED 상태로 전환 - */ public void updateToCompleted(LocalDateTime completedAt) { this.status = QueueEntryStatus.COMPLETED; this.completedAt = completedAt; } -} - +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/queue/error/QueueErrorCode.java b/src/main/java/com/back/b2st/domain/queue/error/QueueErrorCode.java index a5ab94320..0bd7baf3e 100644 --- a/src/main/java/com/back/b2st/domain/queue/error/QueueErrorCode.java +++ b/src/main/java/com/back/b2st/domain/queue/error/QueueErrorCode.java @@ -27,6 +27,7 @@ public enum QueueErrorCode implements ErrorCode { // Redis 연동 관련 REDIS_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Q201", "Redis 연결 오류가 발생했습니다."), REDIS_OPERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Q202", "Redis 작업 중 오류가 발생했습니다."), + QUEUE_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "Q203", "대기열 시스템이 일시적으로 불안정합니다. 잠시 후 다시 시도해주세요."), // 데이터 정합성 관련 QUEUE_DATA_INCONSISTENT(HttpStatus.INTERNAL_SERVER_ERROR, "Q301", "대기열 데이터 불일치가 발생했습니다."), @@ -38,5 +39,4 @@ public enum QueueErrorCode implements ErrorCode { private final HttpStatus status; private final String code; private final String message; -} - +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/queue/metrics/QueueMetrics.java b/src/main/java/com/back/b2st/domain/queue/metrics/QueueMetrics.java new file mode 100644 index 000000000..16e8b5734 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/metrics/QueueMetrics.java @@ -0,0 +1,82 @@ +package com.back.b2st.domain.queue.metrics; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class QueueMetrics { + private final MeterRegistry registry; + private final Map queueWaitingCounts = new ConcurrentHashMap<>(); + private final Map queueEnterableCounts = new ConcurrentHashMap<>(); + + public QueueMetrics(MeterRegistry registry) { + this.registry = registry; + } + + /** 대기열 진입 기록 */ + public void recordQueueEnter(Long queueId) { + Counter.builder("queue_enter_total").tag("queue_id", String.valueOf(queueId)).register(registry).increment(); + getWaitingCount(queueId).incrementAndGet(); + } + + /** 대기열 이탈 기록 (COMPLETED/EXPIRED/CANCELLED) */ + public void recordQueueExit(Long queueId, String reason) { + Counter.builder("queue_exit_total") + .tag("queue_id", String.valueOf(queueId)) + .tag("reason", reason) // COMPLETED, EXPIRED, CANCELLED + .register(registry) + .increment(); + getWaitingCount(queueId).decrementAndGet(); + } + + /** WAITING → ENTERABLE 승격 기록 */ + public void recordMoveToEnterable(Long queueId) { + Counter.builder("queue_enterable_total") + .tag("queue_id", String.valueOf(queueId)) + .register(registry) + .increment(); + getWaitingCount(queueId).decrementAndGet(); + getEnterableCount(queueId).incrementAndGet(); + } + + /** 대기열 입장 완료 기록 */ + public void recordEntryComplete(Long queueId) { + Counter.builder("queue_complete_total").tag("queue_id", String.valueOf(queueId)).register(registry).increment(); + getEnterableCount(queueId).decrementAndGet(); + } + + /** 대기열 통계 갱신 (배치용) */ + public void updateQueueStats(Long queueId, int waiting, int enterable) { + getWaitingCount(queueId).set(waiting); + getEnterableCount(queueId).set(enterable); + } + + private AtomicInteger getWaitingCount(Long queueId) { + return queueWaitingCounts.computeIfAbsent(queueId, id -> { + AtomicInteger count = new AtomicInteger(0); + Gauge.builder("queue_waiting_count", count, AtomicInteger::get) + .tag("queue_id", String.valueOf(id)) + .description("대기열 대기 인원") + .register(registry); + return count; + }); + } + + private AtomicInteger getEnterableCount(Long queueId) { + return queueEnterableCounts.computeIfAbsent(queueId, id -> { + AtomicInteger count = new AtomicInteger(0); + Gauge.builder("queue_enterable_count", count, AtomicInteger::get) + .tag("queue_id", String.valueOf(id)) + .description("입장 가능 인원") + .register(registry); + return count; + }); + } +} diff --git a/src/main/java/com/back/b2st/domain/queue/repository/QueueEntryRepository.java b/src/main/java/com/back/b2st/domain/queue/repository/QueueEntryRepository.java index 40b49b7bd..3896b77ee 100644 --- a/src/main/java/com/back/b2st/domain/queue/repository/QueueEntryRepository.java +++ b/src/main/java/com/back/b2st/domain/queue/repository/QueueEntryRepository.java @@ -5,7 +5,6 @@ import java.util.Optional; import java.util.UUID; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -18,176 +17,103 @@ public interface QueueEntryRepository extends JpaRepository { - /** - * 대기열 ID와 사용자 ID로 조회 - */ + /* ==================== Core Lookups ==================== */ + Optional findByQueueIdAndUserId(Long queueId, Long userId); - /** - * 입장권 토큰으로 조회 - */ Optional findByEntryToken(UUID entryToken); - /** - * 사용자의 모든 입장 기록 조회 - */ - List findByUserId(Long userId); + List findAllByStatus(QueueEntryStatus status); - /** - * 대기열의 모든 입장 기록 조회 - */ - List findByQueueId(Long queueId); + /* ==================== Circuit Breaker Fallback 메서드 ==================== */ /** - * 대기열의 특정 상태 입장 기록 조회 + * Circuit Breaker Fallback: isInEnterable + * Redis 장애 시 DB에서 ENTERABLE 상태 확인 */ - List findByQueueIdAndStatus(Long queueId, QueueEntryStatus status); + boolean existsByQueueIdAndUserIdAndStatus(Long queueId, Long userId, QueueEntryStatus status); /** - * 대기열의 특정 상태 입장 기록 조회 (페이징) + * Circuit Breaker Fallback: getTotalEnterableCount + * Redis 장애 시 DB에서 ENTERABLE 개수 조회 */ - Page findByQueueIdAndStatus(Long queueId, QueueEntryStatus status, Pageable pageable); + long countByQueueIdAndStatus(Long queueId, QueueEntryStatus status); - /** - * 대기열의 활성(ENTERABLE) 입장 수 - * 실시간 체크는 Redis ZCARD 사용, 이 메서드는 통계/분석용 - */ - @Query(""" - SELECT COUNT(qe) FROM QueueEntry qe - WHERE qe.queueId = :queueId - AND qe.status = 'ENTERABLE' - """) - long countActiveEntries(@Param("queueId") Long queueId); + /* ==================== Statistics ==================== */ - /** - * 대기열별 상태별 통계 (DTO Projection) - */ @Query(""" - SELECT qe.status as status, COUNT(qe) as count - FROM QueueEntry qe - WHERE qe.queueId = :queueId - GROUP BY qe.status - """) + SELECT qe.status as status, COUNT(qe) as count + FROM QueueEntry qe + WHERE qe.queueId = :queueId + GROUP BY qe.status + """) List countByStatusGrouped(@Param("queueId") Long queueId); - /** - * 사용자별 완료 횟수 - */ - @Query(""" - SELECT COUNT(qe) FROM QueueEntry qe - WHERE qe.userId = :userId - AND qe.status = 'COMPLETED' - """) - long countCompletedByUser(@Param("userId") Long userId); + /* ==================== Cleanup Targets (Batch Read) ==================== */ - /** - * 만료된 입장 일괄 업데이트 (Redis 동기화용) - */ - @Modifying(clearAutomatically = true) @Query(""" - UPDATE QueueEntry qe - SET qe.status = 'EXPIRED' - WHERE qe.queueId = :queueId - AND qe.userId IN :userIds - AND qe.status = 'ENTERABLE' - """) - int bulkUpdateStatusToExpired(@Param("queueId") Long queueId, @Param("userIds") List userIds); - - /** - * 만료 예정 입장 조회 (배치 작업용) - */ - @Query(""" - SELECT qe FROM QueueEntry qe - WHERE qe.queueId = :queueId - AND qe.status = 'ENTERABLE' - AND qe.expiresAt <= :now - """) - List findExpiredEntries(@Param("queueId") Long queueId, @Param("now") LocalDateTime now); + SELECT qe + FROM QueueEntry qe + WHERE qe.status = :status + AND qe.expiresAt IS NOT NULL + AND qe.expiresAt <= :now + ORDER BY qe.expiresAt ASC + """) + List findExpiredByStatus( + @Param("status") QueueEntryStatus status, + @Param("now") LocalDateTime now, + Pageable pageable + ); - /** - * 전체 대기열에서 만료 예정 입장 조회 - */ @Query(""" - SELECT qe FROM QueueEntry qe - WHERE qe.status = 'ENTERABLE' - AND qe.expiresAt <= :now - """) - List findAllExpiredEntries(@Param("now") LocalDateTime now); + SELECT qe + FROM QueueEntry qe + WHERE qe.status = :status + AND qe.expiresAt IS NOT NULL + AND qe.expiresAt > :now + ORDER BY qe.expiresAt ASC + """) + List findNonExpiredByStatus( + @Param("status") QueueEntryStatus status, + @Param("now") LocalDateTime now, + Pageable pageable + ); - /** - * 입장권 존재 여부 확인 - */ - boolean existsByQueueIdAndUserId(Long queueId, Long userId); + /* ==================== Active Existence ==================== */ - /** - * 입장권 토큰 존재 여부 확인 - */ - boolean existsByEntryToken(UUID entryToken); - - /** - * 사용자가 활성 상태인지 확인 - */ @Query(""" - SELECT CASE WHEN COUNT(qe) > 0 THEN true ELSE false END - FROM QueueEntry qe - WHERE qe.queueId = :queueId - AND qe.userId = :userId - AND qe.status = 'ENTERABLE' - AND qe.expiresAt > :now - """) - boolean isUserActiveInQueue( + SELECT (COUNT(qe) > 0) + FROM QueueEntry qe + WHERE qe.queueId = :queueId + AND qe.userId = :userId + AND qe.status = :status + AND qe.expiresAt IS NOT NULL + AND qe.expiresAt > :now + """) + boolean existsActive( @Param("queueId") Long queueId, @Param("userId") Long userId, + @Param("status") QueueEntryStatus status, @Param("now") LocalDateTime now ); - /** - * 특정 기간 동안의 입장 기록 조회 - */ - @Query(""" - SELECT qe FROM QueueEntry qe - WHERE qe.queueId = :queueId - AND qe.joinedAt BETWEEN :startTime AND :endTime - ORDER BY qe.joinedAt ASC - """) - List findByQueueIdAndJoinedAtBetween( - @Param("queueId") Long queueId, - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime - ); + /* ==================== Optional Bulk Ops ==================== */ - /** - * 특정 시간 이후 입장한 기록 조회 - */ + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" - SELECT qe FROM QueueEntry qe - WHERE qe.queueId = :queueId - AND qe.joinedAt >= :afterTime - ORDER BY qe.joinedAt DESC - """) - List findRecentEntries( + UPDATE QueueEntry qe + SET qe.status = :toStatus + WHERE qe.queueId = :queueId + AND qe.userId IN :userIds + AND qe.status = :fromStatus + """) + int bulkUpdateStatus( @Param("queueId") Long queueId, - @Param("afterTime") LocalDateTime afterTime + @Param("userIds") List userIds, + @Param("fromStatus") QueueEntryStatus fromStatus, + @Param("toStatus") QueueEntryStatus toStatus ); - /** - * 특정 상태의 오래된 기록 삭제 - */ - @Modifying(clearAutomatically = true) - @Query(""" - DELETE FROM QueueEntry qe - WHERE qe.status = :status - AND qe.createdAt < :beforeTime - """) - int deleteOldEntriesByStatus( - @Param("status") QueueEntryStatus status, - @Param("beforeTime") LocalDateTime beforeTime - ); - - /** - * 특정 대기열의 모든 기록 삭제 - */ - @Modifying(clearAutomatically = true) - @Query("DELETE FROM QueueEntry qe WHERE qe.queueId = :queueId") - int deleteByQueueId(@Param("queueId") Long queueId); -} + @Query("SELECT DISTINCT qe.queueId FROM QueueEntry qe") + List findDistinctQueueIds(); +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/queue/repository/QueueRedisRepository.java b/src/main/java/com/back/b2st/domain/queue/repository/QueueRedisRepository.java index e47e1a054..c722c5f20 100644 --- a/src/main/java/com/back/b2st/domain/queue/repository/QueueRedisRepository.java +++ b/src/main/java/com/back/b2st/domain/queue/repository/QueueRedisRepository.java @@ -1,22 +1,41 @@ package com.back.b2st.domain.queue.repository; -import java.time.Duration; import java.util.Arrays; +import java.util.Collections; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Repository; +import com.back.b2st.domain.queue.dto.MoveResult; +import com.back.b2st.domain.queue.entity.QueueEntry; +import com.back.b2st.domain.queue.entity.QueueEntryStatus; +import com.back.b2st.domain.queue.error.QueueErrorCode; +import com.back.b2st.global.error.exception.BusinessException; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * 대기열의 실시간 데이터를 Redis에 저장/조회/관리하는 Repository + * Redis Repository (Queue) + * + * ✅ SoT = ENTERABLE ZSET (member=userId, score=expiresAtSeconds) + * - 유효 판정: score >= nowSeconds + * - 카운트: ZCOUNT(nowSeconds, +inf) + * - 만료 정리: ZREMRANGEBYSCORE(-inf, nowSeconds-1) + * + * ✅ Redis Cluster HashTag 적용 + * - 모든 키에 {queueId} 포함하여 같은 슬롯에 배치 + * + * ✅ Circuit Breaker 적용 + * - 모든 Redis 호출에 Circuit Breaker 적용 + * - Fallback 전략: 중요 메서드는 DB 조회, 나머지는 안전한 기본값 */ @Repository @RequiredArgsConstructor @@ -24,315 +43,387 @@ @ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) public class QueueRedisRepository { - private final RedisTemplate redisTemplate; + private final StringRedisTemplate stringRedisTemplate; + private final QueueEntryRepository queueEntryRepository; // Circuit Breaker Fallback용 - // Lua Scripts (원자적 실행) - @Autowired(required = false) + /** + * Lua Script: WAITING -> ENTERABLE 원자적 이동 + 상한 제어 + * return: + * - 1: MOVED + * - 0: SKIPPED (WAITING 없음) + * - 2: REJECTED_FULL (상한 초과) + */ + @Autowired private RedisScript moveToEnterableScript; - @Autowired(required = false) - private RedisScript removeFromEnterableScript; - - // 환경별 Key Prefix (대규모 트래픽 환경 - Namespace 분리) @Value("${spring.application.name:b2st}") private String appName; @Value("${spring.profiles.active:local}") private String profile; - // Redis Key 패턴 - private static final String WAITING_KEY_PATTERN = "%s:%s:queue:%d:waiting"; // ZSET: 대기 중 - private static final String ENTERABLE_USER_KEY_PATTERN = "%s:%s:queue:%d:enterable:%d"; // STRING:개별 사용자 입장권 - private static final String ENTERABLE_SET_KEY_PATTERN = "%s:%s:queue:%d:enterable"; // SET: 전체 입장 가능자 목록 - private static final String ENTERABLE_COUNT_KEY_PATTERN = "%s:%s:queue:%d:enterable:count"; // STRING: 누적 카운트 + private static final String WAITING_KEY_PATTERN = "%s:%s:queue:{%d}:waiting"; + private static final String ENTERABLE_KEY_PATTERN = "%s:%s:queue:{%d}:enterable"; - /** - * Redis Key 생성 헬퍼 메서드 - */ private String getWaitingKey(Long queueId) { return String.format(WAITING_KEY_PATTERN, appName, profile, queueId); } - private String getEnterableUserKey(Long queueId, Long userId) { - return String.format(ENTERABLE_USER_KEY_PATTERN, appName, profile, queueId, userId); + private String getEnterableKey(Long queueId) { + return String.format(ENTERABLE_KEY_PATTERN, appName, profile, queueId); } - private String getEnterableSetKey(Long queueId) { - return String.format(ENTERABLE_SET_KEY_PATTERN, appName, profile, queueId); + /* ==================== WAITING ==================== */ + + @CircuitBreaker(name = "queueRedis", fallbackMethod = "addToWaitingQueueFallback") + public void addToWaitingQueue(Long queueId, Long userId, long timestampMillis) { + String key = getWaitingKey(queueId); + stringRedisTemplate.opsForZSet().add(key, userId.toString(), timestampMillis); } - private String getEnterableCountKey(Long queueId) { - return String.format(ENTERABLE_COUNT_KEY_PATTERN, appName, profile, queueId); + private void addToWaitingQueueFallback(Long queueId, Long userId, long timestampMillis, Exception e) { + log.error("Circuit Breaker activated - addToWaitingQueue failed for queueId: {}, userId: {}", + queueId, userId, e); + throw new BusinessException(QueueErrorCode.QUEUE_SERVICE_UNAVAILABLE, + "대기열 시스템이 일시적으로 불안정합니다. 잠시 후 다시 시도해주세요."); } - /** - * 대기열에 사용자 추가 - * - * @param queueId 대기열 ID - * @param userId 사용자 ID - * @param timestamp 입장 시각 (score로 사용, 먼저 들어온 순서대로 정렬) - */ - public void addToWaitingQueue(Long queueId, Long userId, long timestamp) { + @CircuitBreaker(name = "queueRedis", fallbackMethod = "removeFromWaitingQueueFallback") + public void removeFromWaitingQueue(Long queueId, Long userId) { String key = getWaitingKey(queueId); - redisTemplate.opsForZSet().add(key, userId.toString(), timestamp); + stringRedisTemplate.opsForZSet().remove(key, userId.toString()); + } - log.debug("Added to waiting queue - queueId: {}, userId: {}, timestamp: {}", queueId, userId, timestamp); + private void removeFromWaitingQueueFallback(Long queueId, Long userId, Exception e) { + log.warn("Circuit Breaker activated - removeFromWaitingQueue failed for queueId: {}, userId: {}", + queueId, userId, e); + // WAITING 제거는 실패해도 크리티컬하지 않음 (스케줄러가 재정리) } - /** - * 대기열에서 사용자 제거 - */ - public void removeFromWaitingQueue(Long queueId, Long userId) { + /** 0-based rank */ + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getMyRank0InWaitingFallback") + public Long getMyRank0InWaiting(Long queueId, Long userId) { String key = getWaitingKey(queueId); - redisTemplate.opsForZSet().remove(key, userId.toString()); + return stringRedisTemplate.opsForZSet().rank(key, userId.toString()); + } - log.debug("Removed from waiting queue - queueId: {}, userId: {}", queueId, userId); + private Long getMyRank0InWaitingFallback(Long queueId, Long userId, Exception e) { + log.warn("Circuit Breaker activated - getMyRank0InWaiting fallback for queueId: {}, userId: {}", + queueId, userId, e); + return null; // 순번을 알 수 없음 } - /** - * 내 현재 순번 조회 (1부터 시작) - * - * @return 1-based 순번 (대기열에 없으면 null) - */ + /** 1-based rank (테스트/편의용) */ + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getMyRankInWaitingFallback") public Long getMyRankInWaiting(Long queueId, Long userId) { - String key = getWaitingKey(queueId); - Long rank = redisTemplate.opsForZSet().rank(key, userId.toString()); - - return rank != null ? rank + 1 : null; + Long rank0 = getMyRank0InWaiting(queueId, userId); + return rank0 == null ? null : (rank0 + 1); } - /** - * 나보다 앞에 대기 중인 사람 수 - */ - public Long getWaitingAheadCount(Long queueId, Long userId) { - Long rank = getMyRankInWaiting(queueId, userId); - return rank != null ? rank - 1 : null; + private Long getMyRankInWaitingFallback(Long queueId, Long userId, Exception e) { + log.warn("Circuit Breaker activated - getMyRankInWaiting fallback for queueId: {}, userId: {}", + queueId, userId, e); + return null; } - /** - * 대기열 총 인원 수 - */ + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getTotalWaitingCountFallback") public Long getTotalWaitingCount(Long queueId) { String key = getWaitingKey(queueId); - Long size = redisTemplate.opsForZSet().size(key); - + Long size = stringRedisTemplate.opsForZSet().size(key); return size != null ? size : 0L; } - /** - * 대기열 상위 N명 조회 (userId 목록) - */ - public Set getTopWaitingUsers(Long queueId, int count) { + private Long getTotalWaitingCountFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - getTotalWaitingCount fallback for queueId: {}", queueId, e); + // WAITING은 DB에 저장 안하므로 알 수 없음 + return 0L; + } + + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getTopWaitingUsersFallback") + public Set getTopWaitingUsers(Long queueId, int count) { + if (count <= 0) return Collections.emptySet(); String key = getWaitingKey(queueId); - return redisTemplate.opsForZSet().range(key, 0, count - 1); + Set users = stringRedisTemplate.opsForZSet().range(key, 0, count - 1); + return users != null ? users : Collections.emptySet(); } - /** - * 대기열 전체 조회 (userId + timestamp 포함) - */ - public Set> getAllWaitingUsersWithScore(Long queueId) { + private Set getTopWaitingUsersFallback(Long queueId, int count, Exception e) { + log.warn("Circuit Breaker activated - getTopWaitingUsers fallback for queueId: {}", queueId, e); + return Collections.emptySet(); + } + + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getAllWaitingUsersWithScoreFallback") + public Set> getAllWaitingUsersWithScore(Long queueId) { String key = getWaitingKey(queueId); - return redisTemplate.opsForZSet().rangeWithScores(key, 0, -1); + Set> result = stringRedisTemplate.opsForZSet().rangeWithScores(key, 0, -1); + return result != null ? result : Collections.emptySet(); } - /** - * 대기열에 존재하는지 확인 - */ + private Set> getAllWaitingUsersWithScoreFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - getAllWaitingUsersWithScore fallback for queueId: {}", queueId, e); + return Collections.emptySet(); + } + + @CircuitBreaker(name = "queueRedis", fallbackMethod = "isInWaitingQueueFallback") public boolean isInWaitingQueue(Long queueId, Long userId) { String key = getWaitingKey(queueId); - Double score = redisTemplate.opsForZSet().score(key, userId.toString()); - + Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); return score != null; } - /** - * WAITING → ENTERABLE 이동 (Lua Script - 원자적 실행) - * - * 대규모 트래픽 환경에서 원자성 보장: - * - 3개의 Redis 명령을 1번의 네트워크 호출로 실행 - * - 중간 실패 시 자동 롤백 - * - 성능 3배 향상 (RTT x3 → RTT x1) - * - * @param queueId 대기열 ID - * @param userId 사용자 ID - * @param ttlMinutes 입장권 유효 시간 (분) - */ - public void moveToEnterable(Long queueId, Long userId, int ttlMinutes) { - // Lua Script 사용 (원자적 실행) - if (moveToEnterableScript != null) { - String waitingKey = getWaitingKey(queueId); - String userKey = getEnterableUserKey(queueId, userId); - String setKey = getEnterableSetKey(queueId); - - redisTemplate.execute( + private boolean isInWaitingQueueFallback(Long queueId, Long userId, Exception e) { + log.warn("Circuit Breaker activated - isInWaitingQueue fallback for queueId: {}, userId: {}", + queueId, userId, e); + // WAITING은 DB에 저장 안하므로 알 수 없음 + return false; + } + + /* ==================== ENTERABLE (SoT: ZSET) ==================== */ + + @CircuitBreaker(name = "queueRedis", fallbackMethod = "moveToEnterableFallback") + public MoveResult moveToEnterable(Long queueId, Long userId, int ttlMinutes, int maxActiveUsers) { + String waitingKey = getWaitingKey(queueId); + String enterableKey = getEnterableKey(queueId); + + long nowSeconds = System.currentTimeMillis() / 1000; + long expiresAtSeconds = nowSeconds + (ttlMinutes * 60L); + + final Long raw; + try { + raw = stringRedisTemplate.execute( moveToEnterableScript, - Arrays.asList(waitingKey, userKey, setKey), + Arrays.asList(waitingKey, enterableKey), userId.toString(), - String.valueOf(ttlMinutes * 60) // 초 단위로 변환 + String.valueOf(expiresAtSeconds), + String.valueOf(nowSeconds), + String.valueOf(maxActiveUsers) ); + } catch (Exception e) { + log.error("Redis Lua execute failed(moveToEnterable) - queueId: {}, userId: {}", queueId, userId, e); + throw new BusinessException(QueueErrorCode.REDIS_OPERATION_FAILED); + } - log.info("Moved to enterable (Lua) - queueId: {}, userId: {}, ttl: {}min", queueId, userId, ttlMinutes); - } else { - // Fallback: Lua Script 없을 때 (개발 환경) - moveToEnterableFallback(queueId, userId, ttlMinutes); + if (raw == null) { + log.error("Redis Lua result null(moveToEnterable) - queueId: {}, userId: {}", queueId, userId); + throw new BusinessException(QueueErrorCode.REDIS_OPERATION_FAILED); + } + + return switch (raw.intValue()) { + case 1 -> MoveResult.MOVED; + case 2 -> MoveResult.REJECTED_FULL; + default -> MoveResult.SKIPPED; + }; + } + + private MoveResult moveToEnterableFallback(Long queueId, Long userId, int ttlMinutes, + int maxActiveUsers, Exception e) { + log.error("Circuit Breaker activated - moveToEnterable fallback for queueId: {}, userId: {}", + queueId, userId, e); + // 스케줄러가 다음 주기에 재시도하도록 SKIPPED 반환 + return MoveResult.SKIPPED; + } + + @CircuitBreaker(name = "queueRedis", fallbackMethod = "removeFromEnterableFallback") + public void removeFromEnterable(Long queueId, Long userId) { + String enterableKey = getEnterableKey(queueId); + try { + stringRedisTemplate.opsForZSet().remove(enterableKey, userId.toString()); + } catch (Exception e) { + log.error("Redis removeFromEnterable failed - queueId: {}, userId: {}", queueId, userId, e); + throw new BusinessException(QueueErrorCode.REDIS_OPERATION_FAILED); } } + private void removeFromEnterableFallback(Long queueId, Long userId, Exception e) { + log.warn("Circuit Breaker activated - removeFromEnterable fallback for queueId: {}, userId: {}", + queueId, userId, e); + // Redis 제거 실패해도 DB에서 상태 변경은 Service에서 처리되므로 로그만 + } + /** - * Fallback: Lua Script 없을 때 사용 (개발 환경) + * ✅ SoT=Redis: ZSET score로 유효성 판정 + * 🔥 가장 중요한 메서드 - 예매 권한 검증에 사용 */ - private void moveToEnterableFallback(Long queueId, Long userId, int ttlMinutes) { - removeFromWaitingQueue(queueId, userId); - - String userKey = getEnterableUserKey(queueId, userId); - redisTemplate.opsForValue().set(userKey, "1", Duration.ofMinutes(ttlMinutes)); + @CircuitBreaker(name = "queueRedis", fallbackMethod = "isInEnterableFallback") + public boolean isInEnterable(Long queueId, Long userId) { + String enterableKey = getEnterableKey(queueId); + long nowSeconds = System.currentTimeMillis() / 1000; - String setKey = getEnterableSetKey(queueId); - redisTemplate.opsForSet().add(setKey, userId.toString()); + Double score = stringRedisTemplate.opsForZSet().score(enterableKey, userId.toString()); + if (score == null) return false; - log.warn("Moved to enterable (Fallback) - queueId: {}, userId: {}", queueId, userId); + return score.longValue() >= nowSeconds; } /** - * ENTERABLE에서 사용자 제거 (Lua Script) + * 🔥 CRITICAL: DB Fallback으로 권한 검증 + * Redis 장애 시에도 예매가 가능하도록 DB에서 검증 */ - public void removeFromEnterable(Long queueId, Long userId) { - // Lua Script 사용 (원자적 실행) - if (removeFromEnterableScript != null) { - String userKey = getEnterableUserKey(queueId, userId); - String setKey = getEnterableSetKey(queueId); - - redisTemplate.execute( - removeFromEnterableScript, - Arrays.asList(userKey, setKey), - userId.toString() - ); + private boolean isInEnterableFallback(Long queueId, Long userId, Exception e) { + log.error("Circuit Breaker activated - isInEnterable fallback (CRITICAL) for queueId: {}, userId: {}", + queueId, userId, e); - log.debug("Removed from enterable (Lua) - queueId: {}, userId: {}", queueId, userId); + // DB에서 ENTERABLE 상태 확인 (중요!) + boolean enterable = queueEntryRepository.existsByQueueIdAndUserIdAndStatus( + queueId, userId, QueueEntryStatus.ENTERABLE); + + if (enterable) { + log.warn("DB fallback success - user {} is ENTERABLE in queue {}", userId, queueId); } else { - // Fallback - removeFromEnterableFallback(queueId, userId); + log.warn("DB fallback - user {} is NOT ENTERABLE in queue {}", userId, queueId); } + + return enterable; } - /** - * Fallback: Lua Script 없을 때 사용 - */ - private void removeFromEnterableFallback(Long queueId, Long userId) { - String userKey = getEnterableUserKey(queueId, userId); - redisTemplate.delete(userKey); + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getTotalEnterableCountFallback") + public Long getTotalEnterableCount(Long queueId) { + String enterableKey = getEnterableKey(queueId); + long nowSeconds = System.currentTimeMillis() / 1000; + + Long count = stringRedisTemplate.opsForZSet().count( + enterableKey, + (double) nowSeconds, + Double.POSITIVE_INFINITY + ); + return count != null ? count : 0L; + } - String setKey = getEnterableSetKey(queueId); - redisTemplate.opsForSet().remove(setKey, userId.toString()); + private Long getTotalEnterableCountFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - getTotalEnterableCount fallback for queueId: {}", queueId, e); - log.warn("Removed from enterable (Fallback) - queueId: {}, userId: {}", queueId, userId); + // DB에서 ENTERABLE 상태 개수 조회 + long count = queueEntryRepository.countByQueueIdAndStatus(queueId, QueueEntryStatus.ENTERABLE); + log.info("DB fallback - ENTERABLE count: {} for queueId: {}", count, queueId); + return count; } - /** - * ENTERABLE 상태인지 확인 - */ - public boolean isInEnterable(Long queueId, Long userId) { - String userKey = getEnterableUserKey(queueId, userId); - return Boolean.TRUE.equals(redisTemplate.hasKey(userKey)); + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getAllEnterableUsersFallback") + public Set getAllEnterableUsers(Long queueId) { + String enterableKey = getEnterableKey(queueId); + long nowSeconds = System.currentTimeMillis() / 1000; + + Set users = stringRedisTemplate.opsForZSet().rangeByScore( + enterableKey, + (double) nowSeconds, + Double.POSITIVE_INFINITY + ); + + return users != null ? users : Collections.emptySet(); + } + + private Set getAllEnterableUsersFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - getAllEnterableUsers fallback for queueId: {}", queueId, e); + return Collections.emptySet(); } /** - * 현재 ENTERABLE 상태인 사람 수 + * ENTERABLE ZSET 만료 정리 (score < nowSeconds) */ - public Long getTotalEnterableCount(Long queueId) { - String setKey = getEnterableSetKey(queueId); - Long size = redisTemplate.opsForSet().size(setKey); + @CircuitBreaker(name = "queueRedis", fallbackMethod = "cleanupExpiredEnterableFallback") + public Long cleanupExpiredEnterable(Long queueId) { + String enterableKey = getEnterableKey(queueId); + long nowSeconds = System.currentTimeMillis() / 1000; + + Long removed = stringRedisTemplate.opsForZSet().removeRangeByScore( + enterableKey, + Double.NEGATIVE_INFINITY, + (double) (nowSeconds - 1) + ); + + return removed != null ? removed : 0L; + } - return size != null ? size : 0L; + private Long cleanupExpiredEnterableFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - cleanupExpiredEnterable fallback for queueId: {}", queueId, e); + // 스케줄러가 다음 주기에 재시도 + return 0L; } /** - * ENTERABLE 사용자 전체 목록 조회 + * (호환용 alias) 기존 코드가 cleanupExpiredFromEnterable를 호출할 수도 있어서 제공 */ - public Set getAllEnterableUsers(Long queueId) { - String setKey = getEnterableSetKey(queueId); - return redisTemplate.opsForSet().members(setKey); + @CircuitBreaker(name = "queueRedis", fallbackMethod = "cleanupExpiredFromEnterableFallback") + public Long cleanupExpiredFromEnterable(Long queueId) { + return cleanupExpiredEnterable(queueId); } - /** - * 입장권 남은 시간 조회 (초) - * - * @return 남은 시간(초), 없으면 null - */ - public Long getEnterableTtl(Long queueId, Long userId) { - String userKey = getEnterableUserKey(queueId, userId); - return redisTemplate.getExpire(userKey); + private Long cleanupExpiredFromEnterableFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - cleanupExpiredFromEnterable fallback for queueId: {}", queueId, e); + return 0L; } + @CircuitBreaker(name = "queueRedis", fallbackMethod = "getEnterableTtlSecondsApproxFallback") + public Long getEnterableTtlSecondsApprox(Long queueId, Long userId) { + String enterableKey = getEnterableKey(queueId); + long nowSeconds = System.currentTimeMillis() / 1000; - /* ==================== 카운터 관련 ==================== */ + Double score = stringRedisTemplate.opsForZSet().score(enterableKey, userId.toString()); + if (score == null) return null; - /** - * ENTERABLE 누적 카운트 증가 - * - * @return 증가된 후의 값 - */ - public Long incrementEnterableCount(Long queueId) { - String key = getEnterableCountKey(queueId); - return redisTemplate.opsForValue().increment(key); + long ttl = score.longValue() - nowSeconds; + return Math.max(ttl, 0L); } - /** - * ENTERABLE 누적 카운트 조회 - */ - public Long getEnterableCount(Long queueId) { - String key = getEnterableCountKey(queueId); - Object count = redisTemplate.opsForValue().get(key); + private Long getEnterableTtlSecondsApproxFallback(Long queueId, Long userId, Exception e) { + log.warn("Circuit Breaker activated - getEnterableTtlSecondsApprox fallback for queueId: {}, userId: {}", + queueId, userId, e); + return null; // TTL 알 수 없음 + } - if (count == null) { - return 0L; - } + @CircuitBreaker(name = "queueRedis", fallbackMethod = "rollbackToWaitingFallback") + public void rollbackToWaiting(Long queueId, Long userId) { + try { + removeFromEnterable(queueId, userId); - // Number로 안전하게 변환 - if (count instanceof Number) { - return ((Number) count).longValue(); - } + long timestamp = System.currentTimeMillis(); + addToWaitingQueue(queueId, userId, timestamp); - // 예상치 못한 타입 발견 시 경고 로그 - log.warn("Unexpected count type: {} for queueId: {}", count.getClass().getSimpleName(), queueId); - return 0L; + log.info("Redis rollback ENTERABLE -> WAITING - queueId: {}, userId: {}", queueId, userId); + } catch (Exception e) { + log.error("Redis rollback failed - queueId: {}, userId: {}", queueId, userId, e); + throw e; + } } - /** - * ENTERABLE 카운트 직접 설정 (테스트용) - */ - public void setEnterableCount(Long queueId, long count) { - String key = getEnterableCountKey(queueId); - redisTemplate.opsForValue().set(key, count); + private void rollbackToWaitingFallback(Long queueId, Long userId, Exception e) { + log.error("Circuit Breaker activated - rollbackToWaiting fallback for queueId: {}, userId: {}", + queueId, userId, e); + // Rollback 실패는 크리티컬 - 에러 전파 + throw new BusinessException(QueueErrorCode.QUEUE_SERVICE_UNAVAILABLE, + "대기열 복구에 실패했습니다. 관리자에게 문의하세요."); } - /** - * 특정 대기열의 모든 Redis 데이터 삭제 (테스트/초기화용) - */ + /* ==================== TEST ONLY ==================== */ + + @org.springframework.boot.autoconfigure.condition.ConditionalOnProperty( + name = "queue.test.enabled", + havingValue = "true", + matchIfMissing = false + ) + @CircuitBreaker(name = "queueRedis", fallbackMethod = "clearAllFallback") public void clearAll(Long queueId) { String waitingKey = getWaitingKey(queueId); - String enterableSetKey = getEnterableSetKey(queueId); - String countKey = getEnterableCountKey(queueId); - - redisTemplate.delete(waitingKey); - redisTemplate.delete(enterableSetKey); - redisTemplate.delete(countKey); - - // ENTERABLE 개별 키들도 삭제 (패턴 매칭) - String pattern = String.format("%s:%s:queue:%d:enterable:*", appName, profile, queueId); - Set keys = redisTemplate.keys(pattern); - if (keys != null && !keys.isEmpty()) { - redisTemplate.delete(keys); - } + String enterableKey = getEnterableKey(queueId); + + stringRedisTemplate.delete(waitingKey); + stringRedisTemplate.delete(enterableKey); - log.info("Cleared all queue data - queueId: {}", queueId); + log.info("Cleared all queue data (TEST ONLY) - queueId: {}", queueId); } - /** - * 대기열이 존재하는지 확인 - */ + private void clearAllFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - clearAll fallback (TEST ONLY) for queueId: {}", queueId, e); + } + + @CircuitBreaker(name = "queueRedis", fallbackMethod = "existsFallback") public boolean exists(Long queueId) { String key = getWaitingKey(queueId); - return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); + } + + private boolean existsFallback(Long queueId, Exception e) { + log.warn("Circuit Breaker activated - exists fallback for queueId: {}", queueId, e); + return false; } -} +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/queue/repository/QueueRepository.java b/src/main/java/com/back/b2st/domain/queue/repository/QueueRepository.java index 4b8059637..1955783ec 100644 --- a/src/main/java/com/back/b2st/domain/queue/repository/QueueRepository.java +++ b/src/main/java/com/back/b2st/domain/queue/repository/QueueRepository.java @@ -14,34 +14,27 @@ public interface QueueRepository extends JpaRepository { /** - * 회차 ID와 대기열 타입으로 대기열 조회 + * 공연 ID로 대기열 조회 + * UNIQUE 제약: (performance_id) */ - Optional findByScheduleIdAndQueueType(Long scheduleId, QueueType queueType); + Optional findByPerformanceId(Long performanceId); - /** - * 회차 ID로 모든 대기열 조회 - */ - List findByScheduleId(Long scheduleId); + void deleteByPerformanceId(Long performanceId); /** - * 대기열 타입으로 조회 + * 대기열 존재 여부 확인 (공연 기준) */ - List findByQueueType(QueueType queueType); + boolean existsByPerformanceId(Long performanceId); /** - * 여러 회차의 대기열 목록 일괄 조회 + * 여러 공연의 대기열 목록 일괄 조회 */ - List findByScheduleIdIn(Collection scheduleIds); + List findByPerformanceIdIn(Collection performanceIds); /** - * 대기열 존재 여부 확인 - */ - boolean existsByScheduleIdAndQueueType(Long scheduleId, QueueType queueType); - - /** - * 특정 회차의 대기열 개수 + * 대기열 타입으로 조회 */ - long countByScheduleId(Long scheduleId); + List findByQueueType(QueueType queueType); /** * 대용량 대기열 조회 (동시 입장 허용 수가 특정 값 이상) diff --git a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEnterableCleanupScheduler.java b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEnterableCleanupScheduler.java new file mode 100644 index 000000000..f69b28e67 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEnterableCleanupScheduler.java @@ -0,0 +1,53 @@ +package com.back.b2st.domain.queue.scheduler; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.queue.entity.Queue; +import com.back.b2st.domain.queue.repository.QueueRedisRepository; +import com.back.b2st.domain.queue.repository.QueueRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * ENTERABLE ZSET 만료(score < nowSeconds) 정리 + */ +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +@Profile("!test") +public class QueueEnterableCleanupScheduler { + + private final QueueRepository queueRepository; + private final QueueRedisRepository queueRedisRepository; + private final SchedulerLeaderLockExecutor lockExecutor; + + @Scheduled(fixedDelayString = "${queue.cleanup.enterable.fixedDelayMs:5000}") + public void cleanupExpiredEnterable() { + lockExecutor.runWithLeaderLock("queue:scheduler:leader:redis-enterable-cleanup", () -> { + List queues = queueRepository.findAll(); + if (queues.isEmpty()) return; + + long totalRemoved = 0; + + for (Queue queue : queues) { + try { + Long removed = queueRedisRepository.cleanupExpiredEnterable(queue.getId()); + if (removed != null && removed > 0) totalRemoved += removed; + } catch (Exception e) { + log.warn("ENTERABLE ZSET 정리 실패 - queueId={}", queue.getId(), e); + } + } + + if (totalRemoved > 0) { + log.info("ENTERABLE ZSET 만료 정리 완료 - 총 제거: {}건", totalRemoved); + } + }); + } +} diff --git a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEntryCleanupScheduler.java b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEntryCleanupScheduler.java new file mode 100644 index 000000000..3fd5505cf --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEntryCleanupScheduler.java @@ -0,0 +1,138 @@ +package com.back.b2st.domain.queue.scheduler; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import com.back.b2st.domain.queue.entity.QueueEntry; +import com.back.b2st.domain.queue.entity.QueueEntryStatus; +import com.back.b2st.domain.queue.repository.QueueEntryRepository; +import com.back.b2st.domain.queue.repository.QueueRedisRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * DB ENTERABLE 정리 (SoT=Redis ENTERABLE ZSET 정책 유지) + * + * 1) expiresAt <= now 인 ENTERABLE -> EXPIRED (Redis와 무관) + * 2) (옵션) expiresAt > now 인데 Redis ZSET에 없으면 stale로 보고 EXPIRED + */ +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +@Profile("!test") +public class QueueEntryCleanupScheduler { + + private static final int BATCH_SIZE = 500; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final QueueEntryRepository queueEntryRepository; + private final QueueRedisRepository queueRedisRepository; + private final SchedulerLeaderLockExecutor lockExecutor; + private final TransactionTemplate transactionTemplate; + + @Value("${queue.cleanup.stale.enabled:false}") + private boolean staleCleanupEnabled; + + @Scheduled(fixedDelayString = "${queue.cleanup.expired.fixedDelayMs:60000}") + public void expireDbEnterablesByExpiresAt() { + lockExecutor.runWithLeaderLock("queue:scheduler:leader:db-expire-by-expiresAt", this::doExpireByExpiresAtLoop); + } + + private void doExpireByExpiresAtLoop() { + try { + LocalDateTime now = LocalDateTime.now(KST); + int totalUpdated = 0; + + while (true) { + Integer batchCount = transactionTemplate.execute(status -> { + List targets = queueEntryRepository.findExpiredByStatus( + QueueEntryStatus.ENTERABLE, + now, + PageRequest.of(0, BATCH_SIZE) + ); + + if (targets.isEmpty()) return 0; + + for (QueueEntry entry : targets) { + entry.updateToExpired(); + } + return targets.size(); // dirty checking + }); + + if (batchCount == null || batchCount == 0) break; + totalUpdated += batchCount; + } + + if (totalUpdated > 0) { + log.info("DB ENTERABLE expired by expiresAt - updated: {}", totalUpdated); + } + } catch (Exception e) { + log.error("Failed to expire DB ENTERABLE by expiresAt", e); + } + } + + @Scheduled(fixedDelayString = "${queue.cleanup.stale.fixedDelayMs:60000}") + public void expireStaleDbEnterablesWithoutRedisZset() { + if (!staleCleanupEnabled) return; + + lockExecutor.runWithLeaderLock("queue:scheduler:leader:db-expire-stale-without-redis", this::doExpireStaleOneShot); + } + + /** + * stale 정리는 무한루프 위험이 있어 1회 처리(one-shot)로 안전하게 운영 + */ + private void doExpireStaleOneShot() { + try { + LocalDateTime now = LocalDateTime.now(KST); + + Integer expiredCount = transactionTemplate.execute(status -> { + List candidates = queueEntryRepository.findExpiredByStatus( + QueueEntryStatus.ENTERABLE, + now, + PageRequest.of(0, BATCH_SIZE) + ); + + if (candidates.isEmpty()) return 0; + + int expiredInBatch = 0; + + for (QueueEntry entry : candidates) { + Long queueId = entry.getQueueId(); + Long userId = entry.getUserId(); + + boolean inEnterable; + try { + inEnterable = queueRedisRepository.isInEnterable(queueId, userId); + } catch (Exception e) { + log.warn("Redis check failed, skip stale cleanup - queueId: {}, userId: {}", queueId, userId, e); + continue; + } + + if (!inEnterable) { + entry.updateToExpired(); + expiredInBatch++; + } + } + + return expiredInBatch; + }); + + if (expiredCount != null && expiredCount > 0) { + log.info("DB stale ENTERABLE expired (no Redis ZSET entry) - updated: {}", expiredCount); + } + } catch (Exception e) { + log.error("Failed to expire stale DB ENTERABLE without Redis ZSET", e); + } + } +} diff --git a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEntryScheduler.java b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEntryScheduler.java index 9cf434453..b8e273b90 100644 --- a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEntryScheduler.java +++ b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueEntryScheduler.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; @@ -15,69 +16,37 @@ import lombok.extern.slf4j.Slf4j; /** - * QueueEntryScheduler - * - * 📌 역할 - * - 대기열을 자동으로 흘려보내는 스케줄러 - * - WAITING 상태의 사용자를 ENTERABLE 상태로 자동 이동 - * - 사람이 버튼을 누르지 않아도 정해진 시간마다 서버가 알아서 처리 - * - * 핵심 동작: - * 1. 모든 활성 대기열 조회 - * 2. 각 대기열별로 입장 가능 인원 계산 - * 3. 상위 N명을 자동으로 입장 처리 - * - * ⚠️ 주의사항 - * - 분산 락이 적용되어 있어 멀티 인스턴스 환경에서도 안전 - * - @Profile("!test")로 테스트 환경에서는 비활성화 (수동 제어) + * WAITING -> ENTERABLE 자동 처리 스케줄러 */ @Component @RequiredArgsConstructor @Slf4j @ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) -@Profile("!test") // 테스트 환경에서는 비활성화 +@Profile("!test") public class QueueEntryScheduler { private final QueueRepository queueRepository; private final QueueSchedulerService queueSchedulerService; + private final SchedulerLeaderLockExecutor lockExecutor; - /** - * 자동 입장 처리 - * - * 실행 주기: 10초마다 (설정으로 변경 가능) - * - 각 대기열별로 대기 중인 사용자를 입장 처리 - * - 한 번에 처리할 인원: 10명 (배치 크기) - * - * @Scheduled(fixedDelay = 10000) - * - 이전 실행이 완료된 후 10초 뒤에 다시 실행 - * - 처리 시간이 길어져도 중복 실행 방지 - */ - @Scheduled(fixedDelay = 10000) // 10초마다 + @Value("${queue.scheduler.batch-size:10}") + private int batchSize; + + @Scheduled(fixedDelayString = "${queue.scheduler.fixed-delay:10000}") public void autoProcessQueueEntries() { - try { - // 1. 모든 활성 대기열 조회 - List activeQueues = queueRepository.findAll(); + lockExecutor.runWithLeaderLock("queue:scheduler:leader:entry", this::processAllQueues); + } - if (activeQueues.isEmpty()) { - log.debug("활성 대기열 없음"); - return; - } + private void processAllQueues() { + List activeQueues = queueRepository.findAll(); + if (activeQueues.isEmpty()) return; - // 2. 각 대기열별로 자동 입장 처리 - for (Queue queue : activeQueues) { - try { - // 분산 락 적용되어 있어 안전하게 처리됨 - queueSchedulerService.processNextEntries(queue.getId(), 10); - } catch (Exception e) { - // 특정 대기열 실패해도 다른 대기열 처리는 계속 - log.error("대기열 자동 입장 처리 실패 - queueId: {}", queue.getId(), e); - } + for (Queue queue : activeQueues) { + try { + queueSchedulerService.processNextEntries(queue.getId(), batchSize); + } catch (Exception e) { + log.error("대기열 자동 입장 처리 실패 - queueId: {}", queue.getId(), e); } - - } catch (Exception e) { - // 스케줄러는 절대 죽으면 안 됨 - log.error("자동 입장 스케줄러 실패", e); } } } - diff --git a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueExpireScheduler.java b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueExpireScheduler.java deleted file mode 100644 index fdf0b1e84..000000000 --- a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueExpireScheduler.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.back.b2st.domain.queue.scheduler; - -import java.time.LocalDateTime; -import java.util.List; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Profile; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import com.back.b2st.domain.queue.entity.QueueEntry; -import com.back.b2st.domain.queue.entity.QueueEntryStatus; -import com.back.b2st.domain.queue.repository.QueueEntryRepository; -import com.back.b2st.domain.queue.repository.QueueRedisRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * QueueExpireScheduler - * - * 📌 역할 - * - 입장 완료(ENTERABLE) 상태인데 결제 시간을 초과한 사용자를 자동으로 만료(EXPIRED) 처리 - * - 시간이 지난 입장권을 정리하여 다음 대기자가 들어올 수 있게 함 - * - * 왜 필요한가? - * - ENTERABLE 상태는 일정 시간(예: 15분) 동안만 유효 - * - 시간 내에 결제하지 않으면 자리를 반납해야 함 - * - 그렇지 않으면 뒤에 있는 대기자들이 영원히 못 들어옴 - * - * 핵심 동작: - * 1. DB에서 만료 시간이 지난 ENTERABLE 항목 조회 - * 2. Redis에서도 제거되었는지 확인 - * 3. EXPIRED 상태로 변경 및 Redis 정리 - * - @Profile("!test")로 테스트 환경에서는 비활성화 (수동 제어) - */ -@Component -@RequiredArgsConstructor -@Slf4j -@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) -@Profile("!test") // 테스트 환경에서는 비활성화 -public class QueueExpireScheduler { - - private final QueueEntryRepository queueEntryRepository; - private final QueueRedisRepository queueRedisRepository; - - /** - * 자동 만료 처리 - * - * 실행 주기: 1분마다 - * - 만료 시간이 지난 ENTERABLE 항목을 EXPIRED로 변경 - * - Redis에서도 제거하여 정합성 유지 - * - * @Scheduled(fixedDelay = 60000) - * - 이전 실행이 완료된 후 1분 뒤에 다시 실행 - */ - @Scheduled(fixedDelay = 60000) // 1분마다 - @Transactional - public void autoExpireEntries() { - try { - LocalDateTime now = LocalDateTime.now(); - - // 1. 만료 시간이 지난 ENTERABLE 항목 조회 - List expiredCandidates = queueEntryRepository - .findAllExpiredEntries(now); - - if (expiredCandidates.isEmpty()) { - log.debug("만료 대상 없음"); - return; - } - - log.info("만료 대상: {}명", expiredCandidates.size()); - - int expiredCount = 0; - - // 2. 각 항목 만료 처리 - for (QueueEntry entry : expiredCandidates) { - try { - // 2-1. EXPIRED 상태로 변경 - if (entry.getStatus() == QueueEntryStatus.ENTERABLE) { - entry.updateToExpired(); - expiredCount++; - - // 2-2. Redis에서도 제거 - try { - queueRedisRepository.removeFromEnterable( - entry.getQueueId(), - entry.getUserId() - ); - } catch (Exception e) { - log.warn("Redis 제거 실패 (DB는 정상 처리) - queueId: {}, userId: {}", - entry.getQueueId(), entry.getUserId(), e); - } - } - } catch (Exception e) { - log.error("항목 만료 처리 실패 - entryId: {}", entry.getId(), e); - } - } - - // 3. 변경사항 일괄 저장 - if (expiredCount > 0) { - queueEntryRepository.saveAll(expiredCandidates); - log.info("만료 처리 완료: {}명", expiredCount); - } - - } catch (Exception e) { - // 스케줄러는 절대 죽으면 안 됨 - log.error("자동 만료 스케줄러 실패", e); - } - } -} - diff --git a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueSyncScheduler.java b/src/main/java/com/back/b2st/domain/queue/scheduler/QueueSyncScheduler.java deleted file mode 100644 index 9425b0c72..000000000 --- a/src/main/java/com/back/b2st/domain/queue/scheduler/QueueSyncScheduler.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.back.b2st.domain.queue.scheduler; - -import java.time.LocalDateTime; -import java.util.List; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import com.back.b2st.domain.queue.entity.QueueEntry; -import com.back.b2st.domain.queue.repository.QueueEntryRepository; -import com.back.b2st.domain.queue.repository.QueueRedisRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * Queue 동기화 스케줄러 - * - * Redis와 DB 간의 데이터 정합성을 유지하기 위한 배치 작업 - * - * 개발 초기 단계 - 대기열 기능 활성화 시에만 로드 - * application.yml에서 `queue.enabled: true` 및 `queue.sync.enabled: true` 설정 필요 - */ -@Component -@RequiredArgsConstructor -@Slf4j -@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) -public class QueueSyncScheduler { - - private final QueueEntryRepository queueEntryRepository; - private final QueueRedisRepository queueRedisRepository; - - /** - * Redis TTL 만료된 항목을 DB에 동기화 - * - * 실행 주기: 5초마다 - * - Redis에서 TTL로 자동 만료된 입장권을 DB에도 EXPIRED로 업데이트 - * - Redis와 DB 간의 상태 불일치 방지 - * - * 대규모 트래픽 환경에서 중요: - * - 초당 수천 건의 만료 처리 가능 - * - 사용자 경험 보장 (재입장 시 정확한 상태 확인) - */ - @Scheduled(fixedRate = 5000) // 5초마다 - @Transactional - public void syncExpiredEntries() { - try { - LocalDateTime now = LocalDateTime.now(); - - // DB에서 만료 예정 항목 조회 (인덱스 활용: status, expires_at) - List expiredCandidates = queueEntryRepository.findAllExpiredEntries(now); - - if (expiredCandidates.isEmpty()) { - return; - } - - int expiredCount = 0; - - // Redis에서 실제로 만료되었는지 확인 후 DB 업데이트 - for (QueueEntry entry : expiredCandidates) { - // Redis에 입장권이 없으면 만료 처리 - if (!queueRedisRepository.isInEnterable(entry.getQueueId(), entry.getUserId())) { - entry.updateToExpired(); - expiredCount++; - } - } - - if (expiredCount > 0) { - queueEntryRepository.saveAll(expiredCandidates); - log.info("Synced {} expired entries to DB", expiredCount); - } - - } catch (Exception e) { - log.error("Failed to sync expired entries", e); - } - } - - /** - * Redis SET 정리 작업 - * 실행 주기: 1분마다 - * - ENTERABLE_SET에서 실제로는 만료된 userId 제거 - * - Redis 메모리 최적화 - */ - @Scheduled(fixedRate = 60000) // 1분마다 - public void cleanupExpiredFromSet() { - try { - // 모든 활성 대기열 조회 - List activeQueueIds = queueEntryRepository.findAll().stream() - .map(entry -> entry.getQueueId()) - .distinct() - .toList(); - - for (Long queueId : activeQueueIds) { - var enterableUsers = queueRedisRepository.getAllEnterableUsers(queueId); - - if (enterableUsers == null || enterableUsers.isEmpty()) { - continue; - } - - int removedCount = 0; - for (Object userIdObj : enterableUsers) { - Long userId = Long.parseLong(userIdObj.toString()); - - // 개별 User Key가 없으면 SET에서도 제거 - if (!queueRedisRepository.isInEnterable(queueId, userId)) { - queueRedisRepository.removeFromEnterable(queueId, userId); - removedCount++; - } - } - - if (removedCount > 0) { - log.debug("Cleaned up {} expired users from SET - queueId: {}", removedCount, queueId); - } - } - - } catch (Exception e) { - log.error("Failed to cleanup expired users from SET", e); - } - } -} - diff --git a/src/main/java/com/back/b2st/domain/queue/scheduler/SchedulerLeaderLockExecutor.java b/src/main/java/com/back/b2st/domain/queue/scheduler/SchedulerLeaderLockExecutor.java new file mode 100644 index 000000000..bb2b36ea6 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/scheduler/SchedulerLeaderLockExecutor.java @@ -0,0 +1,66 @@ +package com.back.b2st.domain.queue.scheduler; + +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 스케줄러 리더 락 실행기 + * + * - redisMode=single: 락 없이 실행 + * - redisMode=cluster: 리더 락 획득한 인스턴스만 실행 + * + * watchdog 활성화: tryLock(waitTime, unit) (leaseTime 미지정) + */ +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +@Profile("!test") +public class SchedulerLeaderLockExecutor { + + private final RedissonClient redissonClient; + + @Value("${spring.data.redis.mode:single}") + private String redisMode; + + @Value("${queue.scheduler.leader-lock.wait-seconds:3}") + private long waitSeconds; + + public void runWithLeaderLock(String lockKey, Runnable task) { + if ("single".equalsIgnoreCase(redisMode)) { + task.run(); + return; + } + + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean acquired = lock.tryLock(waitSeconds, TimeUnit.SECONDS); //watchdog ON + if (!acquired) { + log.debug("Leader lock not acquired - key={}", lockKey); + return; + } + + task.run(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Leader lock interrupted - key={}", lockKey, e); + } catch (Exception e) { + log.error("Leader lock task failed - key={}", lockKey, e); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/src/main/java/com/back/b2st/domain/queue/service/NoOpQueueAccessService.java b/src/main/java/com/back/b2st/domain/queue/service/NoOpQueueAccessService.java new file mode 100644 index 000000000..b06c0b131 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/service/NoOpQueueAccessService.java @@ -0,0 +1,20 @@ +package com.back.b2st.domain.queue.service; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * queue.enabled=false 환경에서 대기열 검증을 스킵하는 구현체 + */ +@Service +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "false", matchIfMissing = true) +public class NoOpQueueAccessService implements QueueAccessService { + + @Override + public void assertEnterable(Long performanceId, Long userId) { + log.debug("[QUEUE-OFF] skip assertEnterable - performanceId: {}, userId: {}", performanceId, userId); + } +} diff --git a/src/main/java/com/back/b2st/domain/queue/service/QueueAccessService.java b/src/main/java/com/back/b2st/domain/queue/service/QueueAccessService.java new file mode 100644 index 000000000..64b2147b6 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/service/QueueAccessService.java @@ -0,0 +1,12 @@ +package com.back.b2st.domain.queue.service; + +public interface QueueAccessService { + + /** + * 사용자가 해당 공연의 대기열을 통과했는지 확인 (권장) + * + * ENTERABLE 상태가 아니면 BusinessException throw + */ + void assertEnterable(Long performanceId, Long userId); + +} diff --git a/src/main/java/com/back/b2st/domain/queue/service/QueueAccessServiceImpl.java b/src/main/java/com/back/b2st/domain/queue/service/QueueAccessServiceImpl.java new file mode 100644 index 000000000..e261dbd52 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/service/QueueAccessServiceImpl.java @@ -0,0 +1,69 @@ +package com.back.b2st.domain.queue.service; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import com.back.b2st.domain.queue.entity.Queue; +import com.back.b2st.domain.queue.error.QueueErrorCode; +import com.back.b2st.domain.queue.repository.QueueRedisRepository; +import com.back.b2st.domain.queue.repository.QueueRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 대기열 접근 제어 서비스 (queue.enabled=true 일 때만 활성) + */ +@Service +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +public class QueueAccessServiceImpl implements QueueAccessService { + + private final QueueRepository queueRepository; + private final QueueRedisRepository queueRedisRepository; + + /** + * (내부용) 사용자가 해당 공연의 대기열을 통과했는지 확인 + * + * @param performanceId 공연 ID + * @param userId 사용자 ID + * @return ENTERABLE 상태면 true, 아니면 false + */ + public boolean isEnterable(Long performanceId, Long userId) { + // 1. 공연의 큐가 존재하는지 확인 + Queue queue = queueRepository.findByPerformanceId(performanceId) + .orElse(null); + + if (queue == null) { + log.debug("Queue not found for performanceId: {}", performanceId); + return false; + } + + // 2. Redis에서 ENTERABLE 상태 확인 (SoT) + try { + boolean enterable = queueRedisRepository.isInEnterable(queue.getId(), userId); + log.debug("isEnterable check - performanceId: {}, userId: {}, queueId: {}, result: {}", + performanceId, userId, queue.getId(), enterable); + return enterable; + } catch (Exception e) { + log.warn("Redis operation failed in isEnterable - performanceId: {}, userId: {}", performanceId, userId, e); + return false; + } + } + + /** + * 사용자가 해당 공연의 대기열을 통과했는지 확인 (권장) + * + * ENTERABLE 상태가 아니면 BusinessException throw + */ + @Override + public void assertEnterable(Long performanceId, Long userId) { + if (!isEnterable(performanceId, userId)) { + log.warn("User not enterable - performanceId: {}, userId: {}", performanceId, userId); + throw new BusinessException(QueueErrorCode.QUEUE_ENTRY_EXPIRED); + } + log.debug("User enterable verified - performanceId: {}, userId: {}", performanceId, userId); + } +} diff --git a/src/main/java/com/back/b2st/domain/queue/service/QueueManagementService.java b/src/main/java/com/back/b2st/domain/queue/service/QueueManagementService.java index 39362b8e8..a0abace4e 100644 --- a/src/main/java/com/back/b2st/domain/queue/service/QueueManagementService.java +++ b/src/main/java/com/back/b2st/domain/queue/service/QueueManagementService.java @@ -1,13 +1,16 @@ package com.back.b2st.domain.queue.service; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.back.b2st.domain.queue.dto.QueueDefaultPolicy; import com.back.b2st.domain.queue.dto.request.CreateQueueReq; import com.back.b2st.domain.queue.dto.request.UpdateQueueReq; import com.back.b2st.domain.queue.dto.response.QueueRes; @@ -23,6 +26,7 @@ /** * Queue 관리 서비스 + * * Queue 엔티티 생성/조회/수정/삭제 담당 */ @Service @@ -35,23 +39,13 @@ public class QueueManagementService { private final QueueRepository queueRepository; private final QueueRedisRepository queueRedisRepository; - /** - * 대기열 생성 - * - * @param request 대기열 생성 요청 - * @return 생성된 대기열 정보 - */ @Transactional public QueueRes createQueue(CreateQueueReq request) { - // 1. QueueType 검증 QueueType queueType = validateQueueType(request.queueType()); + validateNotDuplicated(request.performanceId()); - // 2. 중복 검증 (같은 scheduleId + queueType) - validateNotDuplicated(request.scheduleId(), queueType); - - // 3. Queue 생성 Queue queue = Queue.builder() - .scheduleId(request.scheduleId()) + .performanceId(request.performanceId()) .queueType(queueType) .maxActiveUsers(request.maxActiveUsers()) .entryTtlMinutes(request.entryTtlMinutes()) @@ -59,9 +53,8 @@ public QueueRes createQueue(CreateQueueReq request) { try { queue = queueRepository.save(queue); - log.info("Queue created - queueId: {}, scheduleId: {}, type: {}", - queue.getId(), queue.getScheduleId(), queue.getQueueType()); - + log.info("Queue created - queueId: {}, performanceId: {}, type: {}", + queue.getId(), queue.getPerformanceId(), queue.getQueueType()); return QueueRes.from(queue); } catch (DataAccessException e) { log.error("Failed to create queue", e); @@ -70,52 +63,91 @@ public QueueRes createQueue(CreateQueueReq request) { } /** - * 대기열 단건 조회 + * 공연 ID로 대기열을 조회하거나 생성 (멱등성 보장) + * + * 레이스 컨디션 방어: 동시에 여러 요청이 와도 하나의 큐만 생성됨 * - * @param queueId 대기열 ID - * @return 대기열 정보 (Redis 실시간 정보 포함) + * @param performanceId 공연 ID + * @param policy 기본 정책 (없을 때 생성 시 사용) + * @return 대기열 엔티티 */ + @Transactional + public Queue getOrCreateByPerformanceId(Long performanceId, QueueDefaultPolicy policy) { + // 1. 이미 존재하는지 확인 + return queueRepository.findByPerformanceId(performanceId) + .orElseGet(() -> { + log.info("Queue not found for performanceId: {}, creating new queue", performanceId); + // 2. 생성 시도 + try { + Queue newQueue = Queue.builder() + .performanceId(performanceId) + .queueType(policy.queueType()) + .maxActiveUsers(policy.maxActiveUsers()) + .entryTtlMinutes(policy.entryTtlMinutes()) + .build(); + + Queue saved = queueRepository.save(newQueue); + log.info("Queue created - queueId: {}, performanceId: {}", saved.getId(), performanceId); + return saved; + } catch (DataIntegrityViolationException e) { + // 3. 레이스 컨디션: 다른 스레드가 이미 생성함 + log.debug("Race condition detected, queue already exists for performanceId: {}", performanceId); + return queueRepository.findByPerformanceId(performanceId) + .orElseThrow(() -> { + log.error("Failed to find queue after race condition - performanceId: {}", performanceId); + return new BusinessException(QueueErrorCode.QUEUE_INTERNAL_ERROR); + }); + } + }); + } + public QueueRes getQueue(Long queueId) { - // 1. DB 조회 Queue queue = validateQueue(queueId); - // 2. Redis 실시간 정보 조회 (Fallback) int currentWaiting = getRedisCountWithFallback(() -> queueRedisRepository.getTotalWaitingCount(queueId)); + int currentEnterable = getRedisCountWithFallback(() -> - queueRedisRepository.getEnterableCount(queueId)); + queueRedisRepository.getTotalEnterableCount(queueId)); return QueueRes.of(queue, currentWaiting, currentEnterable); } /** - * 회차별 대기열 목록 조회 + * 공연 ID로 대기열 조회 * - * @param scheduleId 회차 ID - * @return 대기열 목록 + * 공연당 큐는 1개만 존재하므로 (UNIQUE 제약), List로 반환하되 최대 1개만 포함 + * + * @param performanceId 공연 ID + * @return 해당 공연의 대기열 목록 (최대 1개, Redis 실시간 카운트 포함) */ - public List getQueuesBySchedule(Long scheduleId) { - List queues = queueRepository.findByScheduleId(scheduleId); + public List getQueuesByPerformance(Long performanceId) { + log.debug("Getting queues by performanceId: {}", performanceId); + Optional queueOpt = queueRepository.findByPerformanceId(performanceId); - return queues.stream() - .map(queue -> { - // Redis 조회 (Fallback) - int currentWaiting = getRedisCountWithFallback(() -> - queueRedisRepository.getTotalWaitingCount(queue.getId())); - int currentEnterable = getRedisCountWithFallback(() -> - queueRedisRepository.getEnterableCount(queue.getId())); + if (queueOpt.isEmpty()) { + return List.of(); + } - return QueueRes.of(queue, currentWaiting, currentEnterable); - }) - .collect(Collectors.toList()); + Queue queue = queueOpt.get(); + int currentWaiting = getRedisCountWithFallback(() -> + queueRedisRepository.getTotalWaitingCount(queue.getId())); + int currentEnterable = getRedisCountWithFallback(() -> + queueRedisRepository.getTotalEnterableCount(queue.getId())); + + return List.of(QueueRes.of(queue, currentWaiting, currentEnterable)); } /** - * 특정 타입의 대기열 목록 조회 - * - * @param queueType 대기열 타입 - * @return 대기열 목록 + * @deprecated scheduleId 기반 조회는 더 이상 사용하지 않음. performanceId 기반 조회를 사용하세요. */ + @Deprecated + public List getQueuesBySchedule(Long scheduleId) { + // 더 이상 scheduleId로 조회할 수 없으므로 빈 리스트 반환 + log.warn("getQueuesBySchedule is deprecated. Use performanceId-based query instead. scheduleId: {}", scheduleId); + return List.of(); + } + public List getQueuesByType(String queueType) { QueueType type = validateQueueType(queueType); List queues = queueRepository.findByQueueType(type); @@ -126,18 +158,28 @@ public List getQueuesByType(String queueType) { } /** - * 대기열 설정 수정 + * 전체 대기열 목록 조회 * - * @param queueId 대기열 ID - * @param request 수정 요청 - * @return 수정된 대기열 정보 + * @return 모든 대기열 목록 (Redis 실시간 카운트 포함) */ + public List getAllQueues() { + List queues = queueRepository.findAll(); + + return queues.stream() + .map(queue -> { + int currentWaiting = getRedisCountWithFallback(() -> + queueRedisRepository.getTotalWaitingCount(queue.getId())); + int currentEnterable = getRedisCountWithFallback(() -> + queueRedisRepository.getTotalEnterableCount(queue.getId())); + return QueueRes.of(queue, currentWaiting, currentEnterable); + }) + .collect(Collectors.toList()); + } + @Transactional public QueueRes updateQueue(Long queueId, UpdateQueueReq request) { - // 1. Queue 조회 Queue queue = validateQueue(queueId); - // 2. 설정 업데이트 if (request.maxActiveUsers() != null) { queue.updateMaxActiveUsers(request.maxActiveUsers()); } @@ -149,7 +191,6 @@ public QueueRes updateQueue(Long queueId, UpdateQueueReq request) { queue = queueRepository.save(queue); log.info("Queue updated - queueId: {}, maxActiveUsers: {}, entryTtlMinutes: {}", queueId, queue.getMaxActiveUsers(), queue.getEntryTtlMinutes()); - return QueueRes.from(queue); } catch (DataAccessException e) { log.error("Failed to update queue - queueId: {}", queueId, e); @@ -157,53 +198,43 @@ public QueueRes updateQueue(Long queueId, UpdateQueueReq request) { } } - /** - * 대기열 삭제 - * 주의: Redis 데이터도 함께 삭제 - * - * @param queueId 대기열 ID - */ @Transactional public void deleteQueue(Long queueId) { - // 1. Queue 존재 확인 Queue queue = validateQueue(queueId); - // 2. Redis 데이터 삭제 - queueRedisRepository.clearAll(queueId); + try { + queueRedisRepository.clearAll(queueId); + } catch (Exception e) { + log.warn("clearAll() 사용 불가 또는 실패, 개별 키 정리는 스케줄러/운영 정책에 위임 - queueId: {}", queueId, e); + } - // 3. DB 삭제 try { queueRepository.delete(queue); - log.info("Queue deleted - queueId: {}, scheduleId: {}", queueId, queue.getScheduleId()); + log.info("Queue deleted - queueId: {}, performanceId: {}", queueId, queue.getPerformanceId()); } catch (DataAccessException e) { log.error("Failed to delete queue - queueId: {}", queueId, e); throw new BusinessException(QueueErrorCode.QUEUE_INTERNAL_ERROR); } } + public boolean existsQueue(Long performanceId) { + return queueRepository.existsByPerformanceId(performanceId); + } + /** - * 대기열 존재 여부 확인 - * - * @param scheduleId 회차 ID - * @param queueType 대기열 타입 - * @return 존재 여부 + * @deprecated scheduleId 기반 조회는 더 이상 사용하지 않음. performanceId 기반 조회를 사용하세요. */ + @Deprecated public boolean existsQueue(Long scheduleId, String queueType) { - QueueType type = validateQueueType(queueType); - return queueRepository.existsByScheduleIdAndQueueType(scheduleId, type); + log.warn("existsQueue(scheduleId, queueType) is deprecated. Use existsQueue(performanceId) instead."); + return false; } - /** - * Queue 유효성 검증 - */ private Queue validateQueue(Long queueId) { return queueRepository.findById(queueId) .orElseThrow(() -> new BusinessException(QueueErrorCode.QUEUE_NOT_FOUND)); } - /** - * QueueType 검증 - */ private QueueType validateQueueType(String queueTypeStr) { try { return QueueType.valueOf(queueTypeStr.toUpperCase()); @@ -212,20 +243,12 @@ private QueueType validateQueueType(String queueTypeStr) { } } - /** - * 중복 대기열 검증 - */ - private void validateNotDuplicated(Long scheduleId, QueueType queueType) { - if (queueRepository.existsByScheduleIdAndQueueType(scheduleId, queueType)) { + private void validateNotDuplicated(Long performanceId) { + if (queueRepository.existsByPerformanceId(performanceId)) { throw new BusinessException(QueueErrorCode.ALREADY_IN_QUEUE); } } - /** - * Redis 카운트 조회 (Fallback 패턴) - * - * Redis 장애 시 0 반환 - */ private int getRedisCountWithFallback(java.util.function.Supplier redisOperation) { try { Long count = redisOperation.get(); @@ -236,4 +259,3 @@ private int getRedisCountWithFallback(java.util.function.Supplier redisOpe } } } - diff --git a/src/main/java/com/back/b2st/domain/queue/service/QueueSchedulerService.java b/src/main/java/com/back/b2st/domain/queue/service/QueueSchedulerService.java index f3c87dcc6..60512d1c6 100644 --- a/src/main/java/com/back/b2st/domain/queue/service/QueueSchedulerService.java +++ b/src/main/java/com/back/b2st/domain/queue/service/QueueSchedulerService.java @@ -9,7 +9,6 @@ import org.redisson.api.RedissonClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import com.back.b2st.domain.queue.entity.Queue; import com.back.b2st.domain.queue.error.QueueErrorCode; @@ -20,18 +19,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** - * Queue 자동 처리 서비스 (스케줄러용) - * - * 자동 입장 처리, 만료 정리 등 - * - * ⚠️ 분산 락 적용: 멀티 인스턴스 환경에서 중복 실행 방지 - */ @Service @RequiredArgsConstructor @Slf4j @ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) -@Transactional(readOnly = true) public class QueueSchedulerService { private final QueueRepository queueRepository; @@ -39,291 +30,170 @@ public class QueueSchedulerService { private final QueueService queueService; private final RedissonClient redissonClient; - /** - * 대기열 자동 입장 처리 (분산 락 적용) - * - * 스케줄러에서 주기적으로 호출 - * 1. 입장 가능 인원 계산 - * 2. 상위 N명 입장 허용 - * - * @param queueId 대기열 ID - * @param batchSize 한 번에 처리할 인원 (기본: 10명) - */ - @Transactional public void processNextEntries(Long queueId, int batchSize) { - String lockKey = "queue:lock:process:" + queueId; - RLock lock = redissonClient.getLock(lockKey); - - try { - // 락 획득 시도 (최대 3초 대기, 10초 후 자동 해제) - boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); - - if (!acquired) { - log.warn("분산 락 획득 실패 (다른 서버에서 실행 중) - queueId: {}", queueId); - return; - } - - log.debug("분산 락 획득 성공 - queueId: {}", queueId); - - // 실제 처리 로직 - processEntriesInternal(queueId, batchSize); - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("분산 락 획득 중 인터럽트 발생 - queueId: {}", queueId, e); - } catch (Exception e) { - log.error("자동 입장 처리 중 오류 발생 - queueId: {}", queueId, e); - } finally { - // 락 해제 (획득한 스레드만 해제 가능) - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - log.debug("분산 락 해제 - queueId: {}", queueId); - } - } + processEntriesInternal(queueId, batchSize); } - /** - * 실제 입장 처리 로직 (내부 메서드) - */ private void processEntriesInternal(Long queueId, int batchSize) { - // 1. Queue 조회 Queue queue = queueRepository.findById(queueId) .orElseThrow(() -> new BusinessException(QueueErrorCode.QUEUE_NOT_FOUND)); - // 2. 대기 중인 인원 확인 - Long totalWaiting = getTotalWaitingWithFallback(queueId); - if (totalWaiting == 0) { - log.debug("대기 인원 없음 - queueId: {}", queueId); - return; - } + Long totalWaiting = getTotalWaiting(queueId); + if (totalWaiting == null || totalWaiting == 0) return; - // 3. 입장 가능 인원 확인 - Long currentEnterable = getEnterableCountWithFallback(queueId); - int availableSlots = queue.getMaxActiveUsers() - currentEnterable.intValue(); + Long currentEnterable = getEnterableCount(queueId); + if (currentEnterable == null) return; - if (availableSlots <= 0) { - log.debug("입장 가능 인원 없음 - queueId: {}, current: {}, max: {}", - queueId, currentEnterable, queue.getMaxActiveUsers()); - return; - } + int availableSlots = queue.getMaxActiveUsers() - currentEnterable.intValue(); + if (availableSlots <= 0) return; - // 4. 실제 입장시킬 인원 계산 int entryCount = Math.min(batchSize, Math.min(availableSlots, totalWaiting.intValue())); - // 5. 상위 N명 추출 - Set topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, entryCount); - if (topWaitingUsers.isEmpty()) { + Set topWaitingUsers; + try { + topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, entryCount); + } catch (Exception e) { + log.error("Redis 상위 N명 추출 실패 - queueId: {}, entryCount: {}", queueId, entryCount, e); return; } + if (topWaitingUsers == null || topWaitingUsers.isEmpty()) return; + List userIds = topWaitingUsers.stream() - .map(obj -> Long.parseLong(obj.toString())) + .map(Long::parseLong) .collect(Collectors.toList()); - // 6. 입장 처리 processBatchEntries(queueId, userIds); - - log.info("자동 입장 처리 완료 - queueId: {}, 처리 인원: {}명, 남은 대기: {}명", - queueId, userIds.size(), getTotalWaitingWithFallback(queueId)); + log.info("자동 입장 처리 완료 - queueId: {}, 처리 인원: {}명", queueId, userIds.size()); } - /** - * 배치 입장 처리 - */ - @Transactional public void processBatchEntries(Long queueId, List userIds) { - int successCount = 0; - int failCount = 0; + int success = 0; + int fail = 0; for (Long userId : userIds) { try { queueService.moveToEnterable(queueId, userId); - successCount++; + success++; } catch (Exception e) { - failCount++; - log.error("입장 처리 실패 - queueId: {}, userId: {}, error: {}", - queueId, userId, e.getMessage()); + fail++; + log.error("입장 처리 실패 - queueId: {}, userId: {}, error: {}", queueId, userId, e.getMessage()); } } - - log.info("배치 입장 처리 - queueId: {}, 성공: {}명, 실패: {}명", queueId, successCount, failCount); + log.info("배치 입장 처리 - queueId: {}, 성공: {}명, 실패: {}명", queueId, success, fail); } - /** - * 테스트용: 상위 N명 입장 처리 (분산 락 적용) - */ - @Transactional + // ====== TEST UTIL (락 포함) ====== + public int processTopNForTest(Long queueId, int count) { String lockKey = "queue:lock:test:topN:" + queueId; RLock lock = redissonClient.getLock(lockKey); try { boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); - if (!acquired) { - log.warn("[TEST] 분산 락 획득 실패 - queueId: {}", queueId); - return 0; - } + if (!acquired) return 0; - Long totalWaiting = getTotalWaitingWithFallback(queueId); - if (totalWaiting == 0) { - return 0; - } + Long totalWaiting = getTotalWaiting(queueId); + if (totalWaiting == null || totalWaiting == 0) return 0; int actualCount = Math.min(count, totalWaiting.intValue()); - - Set topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, actualCount); - if (topWaitingUsers.isEmpty()) { - return 0; - } + Set topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, actualCount); + if (topWaitingUsers == null || topWaitingUsers.isEmpty()) return 0; List userIds = topWaitingUsers.stream() - .map(obj -> Long.parseLong(obj.toString())) + .map(Long::parseLong) .collect(Collectors.toList()); processBatchEntries(queueId, userIds); - - log.info("[TEST] 상위 {}명 입장 처리 완료 - queueId: {}", userIds.size(), queueId); return userIds.size(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.error("[TEST] 분산 락 인터럽트 - queueId: {}", queueId, e); return 0; } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } + if (lock.isHeldByCurrentThread()) lock.unlock(); } } - /** - * 테스트용: 내 앞 사람들 모두 입장 처리 (분산 락 적용) - */ - @Transactional public int processUntilMeForTest(Long queueId, Long userId) { String lockKey = "queue:lock:test:untilMe:" + queueId + ":" + userId; RLock lock = redissonClient.getLock(lockKey); try { boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); - if (!acquired) { - log.warn("[TEST] 분산 락 획득 실패 - queueId: {}, userId: {}", queueId, userId); - return 0; - } - - // 1. 내 순번 확인 - Long myRank = queueRedisRepository.getMyRankInWaiting(queueId, userId); - if (myRank == null || myRank <= 1) { - log.info("[TEST] 이미 1등이거나 대기열에 없음 - queueId: {}, userId: {}", queueId, userId); - return 0; - } + if (!acquired) return 0; - // 2. 내 앞 사람들만 처리 - int countToProcess = myRank.intValue() - 1; + Long myRank0 = queueRedisRepository.getMyRank0InWaiting(queueId, userId); + if (myRank0 == null || myRank0 <= 0) return 0; // 0이면 이미 1등(앞사람 0명) - Set topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, countToProcess); - if (topWaitingUsers.isEmpty()) { - return 0; - } + int countToProcess = myRank0.intValue(); // 내 앞 사람 수 = rank0 + Set topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, countToProcess); + if (topWaitingUsers == null || topWaitingUsers.isEmpty()) return 0; - // 3. 나를 제외한 사람들만 List userIds = topWaitingUsers.stream() - .map(obj -> Long.parseLong(obj.toString())) + .map(Long::parseLong) .filter(id -> !id.equals(userId)) .collect(Collectors.toList()); - if (userIds.isEmpty()) { - return 0; - } - + if (userIds.isEmpty()) return 0; processBatchEntries(queueId, userIds); - - log.info("[TEST] 내 앞 사람 {}명 입장 처리 완료 - queueId: {}, userId: {}", userIds.size(), queueId, userId); return userIds.size(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.error("[TEST] 분산 락 인터럽트 - queueId: {}, userId: {}", queueId, userId, e); return 0; } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } + if (lock.isHeldByCurrentThread()) lock.unlock(); } } - /** - * 테스트용: 나까지 포함해서 입장 처리 (분산 락 적용) - */ - @Transactional public int processIncludingMeForTest(Long queueId, Long userId) { String lockKey = "queue:lock:test:includeMe:" + queueId + ":" + userId; RLock lock = redissonClient.getLock(lockKey); try { boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); - if (!acquired) { - log.warn("[TEST] 분산 락 획득 실패 - queueId: {}, userId: {}", queueId, userId); - return 0; - } + if (!acquired) return 0; - // 1. 내 순번 확인 - Long myRank = queueRedisRepository.getMyRankInWaiting(queueId, userId); - if (myRank == null) { - log.warn("[TEST] 대기열에 없음 - queueId: {}, userId: {}", queueId, userId); - return 0; - } - - // 2. 나까지 포함해서 처리 - int countToProcess = myRank.intValue(); + Long myRank0 = queueRedisRepository.getMyRank0InWaiting(queueId, userId); + if (myRank0 == null) return 0; - Set topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, countToProcess); - if (topWaitingUsers.isEmpty()) { - return 0; - } + int countToProcess = myRank0.intValue() + 1; // 나 포함 + Set topWaitingUsers = queueRedisRepository.getTopWaitingUsers(queueId, countToProcess); + if (topWaitingUsers == null || topWaitingUsers.isEmpty()) return 0; List userIds = topWaitingUsers.stream() - .map(obj -> Long.parseLong(obj.toString())) + .map(Long::parseLong) .collect(Collectors.toList()); processBatchEntries(queueId, userIds); - - log.info("[TEST] 나 포함 {}명 입장 처리 완료 - queueId: {}, userId: {}", userIds.size(), queueId, userId); return userIds.size(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.error("[TEST] 분산 락 인터럽트 - queueId: {}, userId: {}", queueId, userId, e); return 0; } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } + if (lock.isHeldByCurrentThread()) lock.unlock(); } } - /** - * Redis 대기 인원 조회 (Fallback) - */ - private Long getTotalWaitingWithFallback(Long queueId) { + private Long getTotalWaiting(Long queueId) { try { - return queueRedisRepository.getTotalWaitingCount(queueId); + Long count = queueRedisRepository.getTotalWaitingCount(queueId); + return count != null ? count : 0L; } catch (Exception e) { - log.warn("Redis 조회 실패, 0 반환 - queueId: {}", queueId, e); - return 0L; + log.error("Redis 대기 인원 조회 실패 - queueId: {}", queueId, e); + return null; } } - /** - * Redis 입장 가능 인원 조회 (Fallback) - */ - private Long getEnterableCountWithFallback(Long queueId) { + private Long getEnterableCount(Long queueId) { try { - return queueRedisRepository.getEnterableCount(queueId); + Long count = queueRedisRepository.getTotalEnterableCount(queueId); + return count != null ? count : 0L; } catch (Exception e) { - log.warn("Redis 조회 실패, 0 반환 - queueId: {}", queueId, e); - return 0L; + log.error("Redis 입장 가능 인원 조회 실패 - queueId: {}", queueId, e); + return null; } } } - diff --git a/src/main/java/com/back/b2st/domain/queue/service/QueueService.java b/src/main/java/com/back/b2st/domain/queue/service/QueueService.java index a03542648..06db1ffba 100644 --- a/src/main/java/com/back/b2st/domain/queue/service/QueueService.java +++ b/src/main/java/com/back/b2st/domain/queue/service/QueueService.java @@ -1,16 +1,12 @@ package com.back.b2st.domain.queue.service; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.Optional; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.dao.DataAccessException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - +import com.back.b2st.domain.queue.dto.MoveResult; +import com.back.b2st.domain.queue.dto.QueueDefaultPolicy; +import com.back.b2st.domain.queue.dto.QueueEntryStatusCount; import com.back.b2st.domain.queue.dto.response.QueueEntryRes; -import com.back.b2st.domain.queue.dto.response.QueueStatusRes; +import com.back.b2st.domain.queue.dto.response.QueuePositionRes; +import com.back.b2st.domain.queue.dto.response.QueueStatisticsRes; +import com.back.b2st.domain.queue.dto.response.StartBookingRes; import com.back.b2st.domain.queue.entity.Queue; import com.back.b2st.domain.queue.entity.QueueEntry; import com.back.b2st.domain.queue.entity.QueueEntryStatus; @@ -19,17 +15,20 @@ import com.back.b2st.domain.queue.repository.QueueRedisRepository; import com.back.b2st.domain.queue.repository.QueueRepository; import com.back.b2st.global.error.exception.BusinessException; - +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -/** - * Queue Service - * - * 대기열 시스템의 핵심 비즈니스 로직 - * - Redis: 실시간 순번 관리 - * - PostgreSQL: 영구 저장 및 통계 - */ @Service @RequiredArgsConstructor @Slf4j @@ -37,196 +36,283 @@ @Transactional(readOnly = true) public class QueueService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final QueueRepository queueRepository; private final QueueEntryRepository queueEntryRepository; private final QueueRedisRepository queueRedisRepository; + private final QueueManagementService queueManagementService; + private final ScheduleResolver scheduleResolver; + + private T runRedis(String op, Long queueId, Long userId, Supplier supplier) { + try { + return supplier.get(); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.warn("Redis {} 실패 - queueId: {}, userId: {}", op, queueId, userId, e); + throw new BusinessException(QueueErrorCode.REDIS_OPERATION_FAILED); + } + } + + private void runRedisVoid(String op, Long queueId, Long userId, Runnable runnable) { + runRedis(op, queueId, userId, () -> { + runnable.run(); + return null; + }); + } + + private LocalDateTime nowKst() { + return LocalDateTime.now(KST); + } /** - * 대기열 입장 - * - * 1. 대기열 유효성 검증 - * 2. 중복 입장 체크 - * 3. Redis ZSET 추가 (실시간 순번 관리) + * 대기열 입장 (내부 메서드) * * @param queueId 대기열 ID + * @param performanceId 공연 ID + * @param scheduleId 회차 ID (프론트 UX용) * @param userId 사용자 ID - * @return 입장 결과 (순번, 앞사람 수 포함) + * @return 대기열 입장 결과 */ @Transactional - public QueueEntryRes enterQueue(Long queueId, Long userId) { - // 1. 대기열 유효성 검증 - Queue queue = validateQueue(queueId); - - // 2. 중복 입장 체크 + public QueueEntryRes enterQueue(Long queueId, Long performanceId, Long scheduleId, Long userId) { + validateQueue(queueId); validateNotDuplicated(queueId, userId); - // 3. Redis에 추가 (실시간 순번 관리) long timestamp = Instant.now().toEpochMilli(); - queueRedisRepository.addToWaitingQueue(queueId, userId, (int)timestamp); - - // 4. 현재 순번 조회 - Long myRank = queueRedisRepository.getMyRankInWaiting(queueId, userId); - Long waitingAhead = queueRedisRepository.getWaitingAheadCount(queueId, userId); - log.info("User entered queue (Redis only) - queueId: {}, userId: {}, rank: {}", queueId, userId, myRank); + runRedisVoid("addToWaitingQueue", queueId, userId, + () -> queueRedisRepository.addToWaitingQueue(queueId, userId, timestamp) + ); - // 5. WAITING 상태 응답 (DB 저장 없이 Redis 정보만 반환) - return QueueEntryRes.waiting( - queueId, - userId, - myRank != null ? myRank.intValue() : 0, - waitingAhead != null ? waitingAhead.intValue() : 0 + Long rank0 = runRedis("getMyRank0InWaiting", queueId, userId, + () -> queueRedisRepository.getMyRank0InWaiting(queueId, userId) ); + + if (rank0 == null) { + log.error("Redis 정합성 이슈: 방금 추가했는데 rank가 null - queueId: {}, userId: {}", queueId, userId); + try { + queueRedisRepository.removeFromWaitingQueue(queueId, userId); + } catch (Exception rollbackEx) { + log.error("enterQueue 롤백 실패 - queueId: {}, userId: {}", queueId, userId, rollbackEx); + } + throw new BusinessException(QueueErrorCode.QUEUE_DATA_INCONSISTENT); + } + + int aheadCount = rank0.intValue(); + int myRank = rank0.intValue() + 1; + + return QueueEntryRes.waiting(queueId, performanceId, scheduleId, userId, aheadCount, myRank); } /** - * 내 대기 상태 조회 + * 대기열 입장 (기존 호환성 유지용 - deprecated) * - * Redis 우선 조회 (실시간 순번) + DB Fallback - * Switch 표현식으로 상태별 처리 + * @deprecated performanceId, scheduleId를 포함하는 enterQueue(Long, Long, Long, Long)를 사용하세요. + */ + @Deprecated + @Transactional + public QueueEntryRes enterQueue(Long queueId, Long userId) { + Queue queue = validateQueue(queueId); + // 성능상 이슈가 있을 수 있으나 호환성을 위해 유지 + log.warn("enterQueue(queueId, userId) is deprecated. Use enterQueue(queueId, performanceId, scheduleId, userId) instead."); + return enterQueue(queueId, queue.getPerformanceId(), null, userId); + } + + /** + * 예매 시작: scheduleId로 대기열 자동 생성 및 입장 (Idempotent) * - * @param queueId 대기열 ID + * 공연 단위 큐를 보장: scheduleId → performanceId 변환 후 getOrCreate + * + * Idempotent 동작: + * - 이미 WAITING/ENTERABLE 상태면 409 대신 현재 상태 반환 + * - 프론트는 응답의 status로 화면 렌더링 가능 + * - 재시도/새로고침이 자주 일어나도 안전하게 처리 + * + * @param scheduleId 공연 회차 ID (프론트 UX용 진입 정보) * @param userId 사용자 ID - * @return 현재 상태 (순번, 앞사람 수, 상태) + * @return 예매 시작 응답 (queueId, performanceId, scheduleId, entry 포함) */ - public QueueStatusRes getMyStatus(Long queueId, Long userId) { - // 1. 대기열 유효성 검증 - validateQueue(queueId); + @Transactional + public StartBookingRes startBooking(Long scheduleId, Long userId) { + // 1. scheduleId → performanceId 변환 + Long performanceId = scheduleResolver.resolvePerformanceId(scheduleId); + log.debug("Resolved scheduleId: {} -> performanceId: {}", scheduleId, performanceId); + + // 2. 공연 단위 큐 조회 또는 생성 (멱등성 보장) + Queue queue = queueManagementService.getOrCreateByPerformanceId( + performanceId, + QueueDefaultPolicy.defaultBooking() + ); - // 2. Redis 우선 조회 (Fallback 포함) - try { - // Redis WAITING 체크 - if (queueRedisRepository.isInWaitingQueue(queueId, userId)) { - return buildWaitingResponse(queueId, userId); - } + Long queueId = queue.getId(); + log.info("Queue resolved/created - queueId: {}, performanceId: {}, scheduleId: {}", + queueId, performanceId, scheduleId); - // Redis ENTERABLE 체크 - if (queueRedisRepository.isInEnterable(queueId, userId)) { - return QueueStatusRes.enterable(queueId, userId); - } - } catch (Exception e) { - log.warn("Redis 조회 실패, DB Fallback - queueId: {}, userId: {}", queueId, userId, e); - } + // 3. 이미 WAITING 또는 ENTERABLE 상태인지 확인 (Idempotent) + boolean inWaiting = runRedis("isInWaitingQueue", queueId, userId, + () -> queueRedisRepository.isInWaitingQueue(queueId, userId) + ); - // 3. DB 조회 (Redis 실패 or 없는 경우) - QueueEntry entry = queueEntryRepository.findByQueueIdAndUserId(queueId, userId) - .orElseThrow(() -> new BusinessException(QueueErrorCode.NOT_IN_QUEUE)); + boolean inEnterable = runRedis("isInEnterable", queueId, userId, + () -> queueRedisRepository.isInEnterable(queueId, userId) + ); - // 4. Switch 표현식으로 상태별 처리 - return buildResponseByStatus(queueId, userId, entry); - } + // 4. 이미 대기 중이거나 입장 가능한 상태면 현재 상태 반환 + if (inWaiting || inEnterable) { + log.debug("User already in queue (idempotent) - queueId: {}, userId: {}, inWaiting: {}, inEnterable: {}", + queueId, userId, inWaiting, inEnterable); - /** - * WAITING 상태 응답 빌더 (Redis 기반) - */ - private QueueStatusRes buildWaitingResponse(Long queueId, Long userId) { - try { - Long myRank = queueRedisRepository.getMyRankInWaiting(queueId, userId); - Long waitingAhead = queueRedisRepository.getWaitingAheadCount(queueId, userId); - Long totalWaiting = queueRedisRepository.getTotalWaitingCount(queueId); + QueuePositionRes position = getMyPosition(queueId, userId); + QueueEntryRes entry = convertPositionToEntry(position, performanceId, scheduleId); - return QueueStatusRes.waiting( + return new StartBookingRes( queueId, - userId, - myRank != null ? myRank.intValue() : 0, - waitingAhead != null ? waitingAhead.intValue() : 0, - totalWaiting != null ? totalWaiting.intValue() : 0 + performanceId, + scheduleId, + entry ); - } catch (Exception e) { - log.warn("Redis WAITING 정보 조회 실패 - queueId: {}, userId: {}", queueId, userId, e); - throw e; } + + // 5. 새로운 입장 처리 + QueueEntryRes entry = enterQueue(queueId, performanceId, scheduleId, userId); + + // 6. StartBookingRes 반환 + return new StartBookingRes( + queueId, + performanceId, + scheduleId, + entry + ); } /** - * DB 상태 기반 응답 빌더 (Switch 표현식) + * QueuePositionRes를 QueueEntryRes로 변환 */ - private QueueStatusRes buildResponseByStatus(Long queueId, Long userId, QueueEntry entry) { - return switch (entry.getStatus()) { - case ENTERABLE -> QueueStatusRes.enterable(queueId, userId); - case COMPLETED, EXPIRED -> QueueStatusRes.fromEntry(entry); - }; + private QueueEntryRes convertPositionToEntry(QueuePositionRes position, Long performanceId, Long scheduleId) { + return new QueueEntryRes( + position.queueId(), + performanceId, + scheduleId, + position.userId(), + position.status(), + position.aheadCount(), + position.myRank() + ); + } + + public QueuePositionRes getMyStatus(Long queueId, Long userId) { + return getMyPosition(queueId, userId); + } + + public QueuePositionRes getMyPosition(Long queueId, Long userId) { + validateQueue(queueId); + + boolean inWaiting = runRedis("isInWaitingQueue", queueId, userId, + () -> queueRedisRepository.isInWaitingQueue(queueId, userId) + ); + + if (inWaiting) { + Long rank0 = runRedis("getMyRank0InWaiting", queueId, userId, + () -> queueRedisRepository.getMyRank0InWaiting(queueId, userId) + ); + + if (rank0 == null) { + log.error("Redis 정합성 이슈: WAITING 큐에 있는데 rank가 null - queueId: {}, userId: {}", + queueId, userId); + throw new BusinessException(QueueErrorCode.QUEUE_DATA_INCONSISTENT); + } + + return QueuePositionRes.waiting(queueId, userId, rank0.intValue(), rank0.intValue() + 1); + } + + boolean inEnterable = runRedis("isInEnterable", queueId, userId, + () -> queueRedisRepository.isInEnterable(queueId, userId) + ); + + if (inEnterable) { + return QueuePositionRes.enterable(queueId, userId); + } + + Optional entryOpt = queueEntryRepository.findByQueueIdAndUserId(queueId, userId); + if (entryOpt.isEmpty()) { + return QueuePositionRes.notInQueue(queueId, userId); + } + + return buildResponseByStatus(queueId, userId, entryOpt.get()); } - /** - * 대기 중인 사용자를 입장 가능 상태로 이동 - * - * Scheduler 또는 Admin에서 호출 - * 1. Redis: WAITING → ENTERABLE (TTL 적용) - 먼저 실행 ⭐ - * 2. DB: 처음으로 ENTERABLE 상태로 저장 (expiresAt 설정) - 나중 실행 - * - * ⚠️ DB에는 ENTERABLE부터 저장됨! (WAITING은 Redis에만 존재) - * ⚠️ Redis → DB 순서로 실행하여 트랜잭션 안전성 보장 - * - * @param queueId 대기열 ID - * @param userId 사용자 ID - */ @Transactional public void moveToEnterable(Long queueId, Long userId) { - // 1. Queue 조회 (TTL 설정값 가져오기) Queue queue = validateQueue(queueId); - // 2. Redis 상태 이동 (WAITING → ENTERABLE) - 먼저 실행! - try { - queueRedisRepository.moveToEnterable(queueId, userId, queue.getEntryTtlMinutes()); - } catch (Exception e) { - log.error("Redis 이동 실패, 트랜잭션 롤백 - queueId: {}, userId: {}", queueId, userId, e); - throw new BusinessException(QueueErrorCode.REDIS_OPERATION_FAILED); - } + MoveResult result = runRedis("moveToEnterable", queueId, userId, + () -> queueRedisRepository.moveToEnterable( + queueId, + userId, + queue.getEntryTtlMinutes(), + queue.getMaxActiveUsers() + ) + ); - // 3. 누적 카운트 증가 - try { - queueRedisRepository.incrementEnterableCount(queueId); - } catch (Exception e) { - log.warn("Redis 카운트 증가 실패 (비중요) - queueId: {}", queueId, e); - // 카운트는 실패해도 진행 (통계성 데이터) + if (result == MoveResult.REJECTED_FULL || result == MoveResult.SKIPPED) { + return; } - // 4. DB에 ENTERABLE 상태로 처음 저장 - 나중 실행! - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = nowKst(); LocalDateTime expiresAt = now.plusMinutes(queue.getEntryTtlMinutes()); - QueueEntry entry = QueueEntry.builder() - .queueId(queueId) - .userId(userId) - .joinedAt(now) - .enterableAt(now) - .expiresAt(expiresAt) - .build(); // status는 자동으로 ENTERABLE - try { + QueueEntry entry = queueEntryRepository.findByQueueIdAndUserId(queueId, userId) + .orElseGet(() -> QueueEntry.builder() + .queueId(queueId) + .userId(userId) + .joinedAt(now) + .enterableAt(now) + .expiresAt(expiresAt) + .build() + ); + + entry.updateToEnterable(UUID.randomUUID(), now, now, expiresAt); queueEntryRepository.save(entry); - log.info("User moved to enterable (Redis→DB) - queueId: {}, userId: {}, expiresAt: {}", - queueId, userId, expiresAt); + } catch (DataAccessException e) { - log.error("DB 저장 실패, 트랜잭션 롤백 - queueId: {}, userId: {}", queueId, userId, e); + log.error("DB 저장 실패, Redis 롤백 시도 - queueId: {}, userId: {}", queueId, userId, e); + try { + queueRedisRepository.rollbackToWaiting(queueId, userId); + } catch (Exception rollbackException) { + log.error("Redis 롤백 실패(치명) - queueId: {}, userId: {}", queueId, userId, rollbackException); + } throw new BusinessException(QueueErrorCode.QUEUE_INTERNAL_ERROR); } } - /** - * 입장 완료 처리 - * - * 사용자가 실제로 서비스(예매 등)를 완료했을 때 호출 - * 1. Redis에서 제거 - * 2. DB 상태를 COMPLETED로 변경 (ENTERABLE 상태여야 함) - * - * @param queueId 대기열 ID - * @param userId 사용자 ID - */ @Transactional public void completeEntry(Long queueId, Long userId) { - // 1. 대기열 및 엔트리 검증 (DB에 ENTERABLE로 저장되어 있어야 함) validateQueue(queueId); - QueueEntry entry = validateQueueEntry(queueId, userId); - // 2. 상태 검증 - if (entry.getStatus() != QueueEntryStatus.ENTERABLE) { - throw new BusinessException(QueueErrorCode.INVALID_QUEUE_STATUS); + boolean isEnterable = runRedis("isInEnterable", queueId, userId, + () -> queueRedisRepository.isInEnterable(queueId, userId) + ); + + if (!isEnterable) { + throw new BusinessException(QueueErrorCode.QUEUE_ENTRY_EXPIRED); } - // 3. DB 상태 업데이트 - entry.updateToCompleted(LocalDateTime.now()); + LocalDateTime now = nowKst(); + + QueueEntry entry = queueEntryRepository.findByQueueIdAndUserId(queueId, userId) + .orElseGet(() -> QueueEntry.builder() + .queueId(queueId) + .userId(userId) + .joinedAt(now) + .enterableAt(now) + .expiresAt(now) // 의미 없음(최소값), 즉시 COMPLETED로 전이됨 + .build() + ); + + entry.updateToCompleted(now); try { queueEntryRepository.save(entry); @@ -234,157 +320,97 @@ public void completeEntry(Long queueId, Long userId) { throw new BusinessException(QueueErrorCode.QUEUE_INTERNAL_ERROR); } - // 4. Redis에서 제거 - queueRedisRepository.removeFromEnterable(queueId, userId); - - log.info("User completed entry - queueId: {}, userId: {}", queueId, userId); + try { + queueRedisRepository.removeFromEnterable(queueId, userId); + } catch (Exception e) { + log.warn("Redis 제거 실패(비중요) - queueId: {}, userId: {}", queueId, userId, e); + } } - /** - * 대기열 나가기 (취소) - * - * 사용자가 대기를 포기했을 때 호출 - * - WAITING 상태: Redis에서만 제거 (DB 기록 없음) - * - ENTERABLE 상태: Redis 제거 + DB 상태 EXPIRED로 변경 - * - * @param queueId 대기열 ID - * @param userId 사용자 ID - */ @Transactional public void exitQueue(Long queueId, Long userId) { - // 1. 대기열 검증 validateQueue(queueId); - // 2. Redis WAITING 상태 체크 - if (queueRedisRepository.isInWaitingQueue(queueId, userId)) { - // WAITING 상태: Redis에서만 제거 - queueRedisRepository.removeFromWaitingQueue(queueId, userId); - log.info("User exited queue (WAITING, Redis only) - queueId: {}, userId: {}", queueId, userId); + boolean inWaiting = runRedis("isInWaitingQueue", queueId, userId, + () -> queueRedisRepository.isInWaitingQueue(queueId, userId) + ); + + if (inWaiting) { + runRedisVoid("removeFromWaitingQueue", queueId, userId, + () -> queueRedisRepository.removeFromWaitingQueue(queueId, userId) + ); return; } - // 3. Redis ENTERABLE 상태 체크 - if (queueRedisRepository.isInEnterable(queueId, userId)) { - // ENTERABLE 상태: Redis 제거 + DB 상태 변경 - queueRedisRepository.removeFromEnterable(queueId, userId); + boolean inEnterable = runRedis("isInEnterable", queueId, userId, + () -> queueRedisRepository.isInEnterable(queueId, userId) + ); - // DB 엔트리 조회 및 상태 변경 - Optional entryOpt = queueEntryRepository.findByQueueIdAndUserId(queueId, userId); - if (entryOpt.isPresent()) { - QueueEntry entry = entryOpt.get(); - entry.updateToExpired(); + if (inEnterable) { + runRedisVoid("removeFromEnterable", queueId, userId, + () -> queueRedisRepository.removeFromEnterable(queueId, userId) + ); - try { + queueEntryRepository.findByQueueIdAndUserId(queueId, userId).ifPresent(entry -> { + if (entry.getStatus() == QueueEntryStatus.ENTERABLE) { + entry.updateToExpired(); queueEntryRepository.save(entry); - } catch (DataAccessException e) { - throw new BusinessException(QueueErrorCode.QUEUE_INTERNAL_ERROR); } - } - - log.info("User exited queue (ENTERABLE) - queueId: {}, userId: {}", queueId, userId); - return; + }); } - - // 4. 대기열에 없음 - throw new BusinessException(QueueErrorCode.NOT_IN_QUEUE); } - /** - * 대기열 통계 조회 - * - * @param queueId 대기열 ID - * @return 대기 중, 입장 가능, 완료 등의 통계 - */ - public QueueStatusRes getQueueStatistics(Long queueId) { - // 1. Queue 조회 + public QueueStatisticsRes getQueueStatisticsForAdmin(Long queueId) { Queue queue = validateQueue(queueId); - // 2. Redis 실시간 데이터 - Long totalWaiting = queueRedisRepository.getTotalWaitingCount(queueId); - Long totalEnterable = queueRedisRepository.getEnterableCount(queueId); + Long totalWaiting = runRedis("getTotalWaitingCount", queueId, null, + () -> queueRedisRepository.getTotalWaitingCount(queueId) + ); + + Long totalEnterable = runRedis("getTotalEnterableCount", queueId, null, + () -> queueRedisRepository.getTotalEnterableCount(queueId) + ); + + List statusCounts = queueEntryRepository.countByStatusGrouped(queueId); - // 3. 통계 반환 - return QueueStatusRes.statistics( + return QueueStatisticsRes.of( queueId, totalWaiting != null ? totalWaiting.intValue() : 0, totalEnterable != null ? totalEnterable.intValue() : 0, - queue.getMaxActiveUsers() + queue.getMaxActiveUsers(), + statusCounts ); } - /** - * 대기열 입장 가능 인원 확인 - * - * @param queueId 대기열 ID - * @return 입장 가능 여부 - */ - public boolean canEnterMore(Long queueId) { - Queue queue = validateQueue(queueId); - - Long currentEnterable = queueRedisRepository.getEnterableCount(queueId); - int current = currentEnterable != null ? currentEnterable.intValue() : 0; - - return current < queue.getMaxActiveUsers(); - } - - /** - * 다음 입장 허용 인원 계산 - * - * @param queueId 대기열 ID - * @return 입장 가능한 인원 수 - */ - public int getAvailableSlots(Long queueId) { - Queue queue = validateQueue(queueId); - - Long currentEnterable = queueRedisRepository.getEnterableCount(queueId); - int current = currentEnterable != null ? currentEnterable.intValue() : 0; - - return Math.max(0, queue.getMaxActiveUsers() - current); + private QueuePositionRes buildResponseByStatus(Long queueId, Long userId, QueueEntry entry) { + return switch (entry.getStatus()) { + case ENTERABLE -> QueuePositionRes.expired(queueId, userId); // SoT=Redis + case EXPIRED -> QueuePositionRes.expired(queueId, userId); + case COMPLETED -> QueuePositionRes.completed(queueId, userId); + default -> QueuePositionRes.notInQueue(queueId, userId); + }; } - /** - * 대기열 유효성 검증 - */ private Queue validateQueue(Long queueId) { return queueRepository.findById(queueId) .orElseThrow(() -> new BusinessException(QueueErrorCode.QUEUE_NOT_FOUND)); } - /** - * 대기열 엔트리 검증 - */ - private QueueEntry validateQueueEntry(Long queueId, Long userId) { - return queueEntryRepository.findByQueueIdAndUserId(queueId, userId) - .orElseThrow(() -> new BusinessException(QueueErrorCode.NOT_IN_QUEUE)); - } - - /** - * 중복 입장 검증 - * - Redis WAITING 체크 - * - Redis ENTERABLE 체크 - * - DB에서 ENTERABLE, COMPLETED 상태 체크 - */ private void validateNotDuplicated(Long queueId, Long userId) { - // 1. Redis WAITING 체크 - if (queueRedisRepository.isInWaitingQueue(queueId, userId)) { - throw new BusinessException(QueueErrorCode.ALREADY_IN_QUEUE); - } + boolean duplicated = runRedis("validateNotDuplicated(redis)", queueId, userId, + () -> queueRedisRepository.isInWaitingQueue(queueId, userId) + || queueRedisRepository.isInEnterable(queueId, userId) + ); - // 2. Redis ENTERABLE 체크 - if (queueRedisRepository.isInEnterable(queueId, userId)) { + if (duplicated) { throw new BusinessException(QueueErrorCode.ALREADY_IN_QUEUE); } - // 3. DB에서 ENTERABLE 또는 COMPLETED 상태 체크 - Optional existingEntry = queueEntryRepository.findByQueueIdAndUserId(queueId, userId); - if (existingEntry.isPresent()) { - QueueEntry entry = existingEntry.get(); - if (entry.getStatus() == QueueEntryStatus.ENTERABLE - || entry.getStatus() == QueueEntryStatus.COMPLETED) { - throw new BusinessException(QueueErrorCode.ALREADY_IN_QUEUE); - } - // EXPIRED 상태는 재입장 가능 (체크하지 않음) - } + //EXIT/COMPLETE 이후엔 "처음부터 재진입" 허용 + //DB의 COMPLETED/EXPIRED 기록은 히스토리로만 남기고 재진입을 막지 않는다. + //Optional existingEntry = queueEntryRepository.findByQueueIdAndUserId(queueId, userId); + //if (existingEntry.isPresent() && existingEntry.get().getStatus() == QueueEntryStatus.COMPLETED) { + // throw new BusinessException(QueueErrorCode.ALREADY_IN_QUEUE); + //} } } - diff --git a/src/main/java/com/back/b2st/domain/queue/service/ScheduleResolver.java b/src/main/java/com/back/b2st/domain/queue/service/ScheduleResolver.java new file mode 100644 index 000000000..10b15e816 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/queue/service/ScheduleResolver.java @@ -0,0 +1,46 @@ +package com.back.b2st.domain.queue.service; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.global.error.code.CommonErrorCode; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * ScheduleId를 PerformanceId로 변환하는 Resolver + * + * 대기열 도메인에서 scheduleId(회차)를 performanceId(공연)로 변환하는 책임 담당 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +public class ScheduleResolver { + + private final PerformanceScheduleRepository performanceScheduleRepository; + + /** + * scheduleId를 performanceId로 변환 + * + * @param scheduleId 공연 회차 ID + * @return 공연 ID + * @throws BusinessException scheduleId가 존재하지 않을 때 + */ + public Long resolvePerformanceId(Long scheduleId) { + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) + .orElseThrow(() -> { + log.warn("Schedule not found - scheduleId: {}", scheduleId); + return new BusinessException(CommonErrorCode.NOT_FOUND); + }); + + Long performanceId = schedule.getPerformance().getPerformanceId(); + log.debug("Resolved scheduleId: {} -> performanceId: {}", scheduleId, performanceId); + return performanceId; + } +} + diff --git a/src/main/java/com/back/b2st/domain/reservation/controller/AdminReservationController.java b/src/main/java/com/back/b2st/domain/reservation/controller/AdminReservationController.java new file mode 100644 index 000000000..4fab27835 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/controller/AdminReservationController.java @@ -0,0 +1,54 @@ +package com.back.b2st.domain.reservation.controller; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.reservation.dto.response.AdminReservationSummaryRes; +import com.back.b2st.domain.reservation.dto.response.ReservationDetailWithPaymentRes; +import com.back.b2st.domain.reservation.entity.ReservationStatus; +import com.back.b2st.domain.reservation.service.AdminReservationService; +import com.back.b2st.global.common.BaseResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/reservations") +public class AdminReservationController { + + private final AdminReservationService adminReservationService; + + @GetMapping + public BaseResponse> getList( + @RequestParam(required = false) ReservationStatus status, + @RequestParam(required = false) Long scheduleId, + @RequestParam(required = false) Long memberId, + @RequestParam(defaultValue = "0") int page + ) { + Pageable pageable = PageRequest.of(page, 20, Sort.by("createdAt").descending()); + + return BaseResponse.success( + adminReservationService.getReservationsByStatus(status, scheduleId, memberId, pageable) + ); + } + + @GetMapping("/{reservationId}") + public BaseResponse getDetail(@PathVariable Long reservationId) { + return BaseResponse.success( + adminReservationService.getReservationDetail(reservationId)); + } + + @PostMapping("/{reservationId}/cancel") + public BaseResponse cancel(@PathVariable Long reservationId) { + adminReservationService.forceCancel(reservationId); + return BaseResponse.success(); + } +} diff --git a/src/main/java/com/back/b2st/domain/reservation/controller/LotteryReservationController.java b/src/main/java/com/back/b2st/domain/reservation/controller/LotteryReservationController.java new file mode 100644 index 000000000..e2f8abd5f --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/controller/LotteryReservationController.java @@ -0,0 +1,49 @@ +package com.back.b2st.domain.reservation.controller; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.reservation.dto.request.ConfirmAssignedSeatsReq; +import com.back.b2st.domain.reservation.dto.response.LotteryReservationCreatedRes; +import com.back.b2st.domain.reservation.service.LotteryReservationService; +import com.back.b2st.global.common.BaseResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/lottery/reservations") +@RequiredArgsConstructor +public class LotteryReservationController { + + private final LotteryReservationService lotteryReservationService; + + /** === 추첨 예매 확정 (결제 완료 기준) === */ + @PostMapping + public BaseResponse createCompletedReservation( + @RequestParam Long memberId, + @RequestParam Long scheduleId + ) { + LotteryReservationCreatedRes reservation = + lotteryReservationService.createCompletedReservation(memberId, scheduleId); + + return BaseResponse.created(reservation); + } + + /** === 추첨 좌석 확정 (관리자/배치용) === */ + @PostMapping("/{reservationId}/seats/confirm") + public BaseResponse confirmAssignedSeats( + @PathVariable Long reservationId, + @Valid @RequestBody ConfirmAssignedSeatsReq req + ) { + lotteryReservationService.confirmAssignedSeats( + reservationId, req.scheduleId(), req.scheduleSeatIds() + ); + return BaseResponse.created(null); + } + +} diff --git a/src/main/java/com/back/b2st/domain/reservation/controller/ReservationApi.java b/src/main/java/com/back/b2st/domain/reservation/controller/ReservationApi.java index 73e57d82f..6d5ce9ce8 100644 --- a/src/main/java/com/back/b2st/domain/reservation/controller/ReservationApi.java +++ b/src/main/java/com/back/b2st/domain/reservation/controller/ReservationApi.java @@ -6,127 +6,77 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import com.back.b2st.domain.reservation.dto.request.ReservationReq; import com.back.b2st.domain.reservation.dto.response.ReservationCreateRes; -import com.back.b2st.domain.reservation.dto.response.ReservationDetailRes; import com.back.b2st.domain.reservation.dto.response.ReservationDetailWithPaymentRes; +import com.back.b2st.domain.reservation.dto.response.ReservationRes; +import com.back.b2st.global.annotation.CurrentUser; import com.back.b2st.global.common.BaseResponse; import com.back.b2st.security.UserPrincipal; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "예매", description = "예매 생성 / 조회 / 취소 API") +@Tag(name = "예매", description = "예매 생성/취소 및 조회") +@RequestMapping("/api/reservations") +@SecurityRequirement(name = "Authorization") public interface ReservationApi { - /* ========================= - * 1. 예매 생성 (결제 시작) - * ========================= */ - @Operation( - summary = "예매 생성 (결제 시작)", - description = """ - 좌석 HOLD가 완료된 상태에서 예매(PENDING)를 생성합니다. - - 로그인 사용자만 가능 - - 본인이 HOLD한 좌석만 생성 가능 - - HOLD TTL 만료 시 생성 불가 (410 GONE) - - 이미 활성 상태(PENDING/COMPLETED) 예매가 존재하면 생성 불가 (409 CONFLICT) - """ - ) + @Operation(summary = "예매 생성", description = "사용자가 예매를 생성합니다.") @ApiResponses({ - @ApiResponse(responseCode = "201", description = "예매(PENDING) 생성 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패"), - @ApiResponse(responseCode = "403", description = "HOLD 소유자가 아님 (SEAT_HOLD_FORBIDDEN)"), - @ApiResponse(responseCode = "404", description = "좌석 정보 없음 (SEAT_NOT_FOUND)"), - @ApiResponse(responseCode = "409", description = """ - 상태 충돌 - - RESERVATION_ALREADY_EXISTS (활성 상태 예매 중복) - - SEAT_NOT_HOLD (좌석이 HOLD 상태가 아님) - """), - @ApiResponse(responseCode = "410", description = "좌석 선점 시간이 만료됨 (SEAT_HOLD_EXPIRED)") + @ApiResponse(responseCode = "201", description = "예매 생성 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)"), + @ApiResponse(responseCode = "404", description = "회차/좌석 등 대상 정보 없음"), + @ApiResponse(responseCode = "409", description = "상태 충돌 (좌석 선점/만료/중복 등)") }) @PostMapping BaseResponse createReservation( - @Parameter(hidden = true) - UserPrincipal user, - @RequestBody(description = "예매 요청 정보 (scheduleId, seatId)") - ReservationReq request + @Parameter(hidden = true) @CurrentUser UserPrincipal user, + @RequestBody ReservationReq request ); - /* ========================= - * 2. 예매 취소 - * ========================= */ - @Operation( - summary = "예매 취소", - description = """ - 예매를 취소합니다. - - 본인 예매만 취소 가능 - - 현재 상태에서 취소가 불가능하면 실패합니다. (INVALID_RESERVATION_STATUS) - - 취소 시 좌석은 HOLD → AVAILABLE로 복구됩니다. - """ - ) + @Operation(summary = "예매 취소", description = "사용자가 본인 예매를 취소합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "취소 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패"), - @ApiResponse(responseCode = "403", description = "본인의 예매가 아님 (RESERVATION_FORBIDDEN)"), - @ApiResponse(responseCode = "404", description = "예매 정보 없음 (RESERVATION_NOT_FOUND)"), - @ApiResponse(responseCode = "409", description = "현재 예매 상태에서는 요청 불가 (INVALID_RESERVATION_STATUS)") + @ApiResponse(responseCode = "200", description = "예매 취소 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)"), + @ApiResponse(responseCode = "403", description = "권한 없음 (본인 예매 아님)"), + @ApiResponse(responseCode = "404", description = "예매 정보 없음") }) @DeleteMapping("/{reservationId}") BaseResponse cancelReservation( @Parameter(description = "예매 ID", example = "1") @PathVariable Long reservationId, - @Parameter(hidden = true) - UserPrincipal user + @Parameter(hidden = true) @CurrentUser UserPrincipal user ); - /* ========================= - * 3. 예매 상세 조회 (결제 정보 포함) - * ========================= */ - @Operation( - summary = "예매 상세 조회 (결제 정보 포함)", - description = """ - 예매 ID로 예매 상세 정보를 조회합니다. - - 공연 / 회차 / 좌석 정보 포함 - - 결제 정보(payment) 함께 반환 - - 로그인 사용자 본인 예매만 조회 가능 - """ - ) + @Operation(summary = "예매 상세 조회", description = "예매 상세 및 결제 정보를 함께 조회합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패"), - @ApiResponse(responseCode = "404", description = "예매 정보 없음 (RESERVATION_NOT_FOUND)") + @ApiResponse(responseCode = "200", description = "예매 상세 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)"), + @ApiResponse(responseCode = "403", description = "권한 없음 (본인 예매 아님)"), + @ApiResponse(responseCode = "404", description = "예매 정보 없음") }) @GetMapping("/{reservationId}") BaseResponse getReservationDetail( @Parameter(description = "예매 ID", example = "1") @PathVariable Long reservationId, - @Parameter(hidden = true) - UserPrincipal user + @Parameter(hidden = true) @CurrentUser UserPrincipal user ); - /* ========================= - * 4. 내 예매 목록 조회 (디테일) - * ========================= */ - @Operation( - summary = "내 예매 목록 조회 (디테일)", - description = """ - 로그인한 사용자의 모든 예매 내역을 조회합니다. - - 공연 / 회차 / 좌석 정보 포함 - - 결제 정보는 포함되지 않습니다. - """ - ) + @Operation(summary = "내 예매 목록 조회", description = "로그인 사용자의 예매 목록을 조회합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패") + @ApiResponse(responseCode = "200", description = "내 예매 목록 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)") }) @GetMapping("/me") - BaseResponse> getMyReservationsDetail( - @Parameter(hidden = true) - UserPrincipal user + BaseResponse> getMyReservations( + @Parameter(hidden = true) @CurrentUser UserPrincipal user ); } diff --git a/src/main/java/com/back/b2st/domain/reservation/controller/ReservationController.java b/src/main/java/com/back/b2st/domain/reservation/controller/ReservationController.java index db6b424a0..f3ff4f7fe 100644 --- a/src/main/java/com/back/b2st/domain/reservation/controller/ReservationController.java +++ b/src/main/java/com/back/b2st/domain/reservation/controller/ReservationController.java @@ -10,13 +10,13 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.back.b2st.domain.payment.dto.response.PaymentConfirmRes; import com.back.b2st.domain.payment.service.PaymentViewService; import com.back.b2st.domain.reservation.dto.request.ReservationReq; import com.back.b2st.domain.reservation.dto.response.ReservationCreateRes; -import com.back.b2st.domain.reservation.dto.response.ReservationDetailRes; import com.back.b2st.domain.reservation.dto.response.ReservationDetailWithPaymentRes; +import com.back.b2st.domain.reservation.dto.response.ReservationRes; import com.back.b2st.domain.reservation.service.ReservationService; +import com.back.b2st.domain.reservation.service.ReservationViewService; import com.back.b2st.global.annotation.CurrentUser; import com.back.b2st.global.common.BaseResponse; import com.back.b2st.security.UserPrincipal; @@ -29,6 +29,7 @@ public class ReservationController implements ReservationApi { private final ReservationService reservationService; + private final ReservationViewService reservationViewService; private final PaymentViewService paymentViewService; /** === 예매 생성 === */ @@ -59,24 +60,20 @@ public BaseResponse getReservationDetail( @PathVariable Long reservationId, @CurrentUser UserPrincipal user ) { - ReservationDetailRes reservation = - reservationService.getReservationDetail(reservationId, user.getId()); - PaymentConfirmRes payment = - paymentViewService.getByReservationId(reservationId, user.getId()); + ReservationDetailWithPaymentRes reservation = + reservationViewService.getReservationDetail(reservationId, user.getId()); - return BaseResponse.success( - new ReservationDetailWithPaymentRes(reservation, payment) - ); + return BaseResponse.success(reservation); } /** === 전체 예매 조회 === */ @GetMapping("/me") - public BaseResponse> getMyReservationsDetail( + public BaseResponse> getMyReservations( @CurrentUser UserPrincipal user ) { Long memberId = user.getId(); - List reservations = reservationService.getMyReservationsDetail(memberId); + List reservations = reservationViewService.getMyReservations(memberId); return BaseResponse.success(reservations); } } \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/request/ConfirmAssignedSeatsReq.java b/src/main/java/com/back/b2st/domain/reservation/dto/request/ConfirmAssignedSeatsReq.java new file mode 100644 index 000000000..1ab92b09c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/dto/request/ConfirmAssignedSeatsReq.java @@ -0,0 +1,12 @@ +package com.back.b2st.domain.reservation.dto.request; + +import java.util.List; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record ConfirmAssignedSeatsReq( + @NotNull Long scheduleId, + @NotEmpty List scheduleSeatIds +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/request/ReservationReq.java b/src/main/java/com/back/b2st/domain/reservation/dto/request/ReservationReq.java index fda7253b4..7b73e0eaa 100644 --- a/src/main/java/com/back/b2st/domain/reservation/dto/request/ReservationReq.java +++ b/src/main/java/com/back/b2st/domain/reservation/dto/request/ReservationReq.java @@ -1,9 +1,11 @@ package com.back.b2st.domain.reservation.dto.request; import java.time.LocalDateTime; +import java.util.List; import com.back.b2st.domain.reservation.entity.Reservation; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; public record ReservationReq( @@ -11,14 +13,13 @@ public record ReservationReq( @NotNull Long scheduleId, - @NotNull - Long seatId + @NotEmpty + List seatIds ) { public Reservation toEntity(Long memberId, LocalDateTime expiresAt) { return Reservation.builder() .scheduleId(scheduleId) .memberId(memberId) - .seatId(seatId) .expiresAt(expiresAt) .build(); } diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/response/AdminReservationSummaryRes.java b/src/main/java/com/back/b2st/domain/reservation/dto/response/AdminReservationSummaryRes.java new file mode 100644 index 000000000..509554ca5 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/dto/response/AdminReservationSummaryRes.java @@ -0,0 +1,19 @@ +package com.back.b2st.domain.reservation.dto.response; + +import java.time.LocalDateTime; + +import com.back.b2st.domain.reservation.entity.ReservationStatus; + +import lombok.Builder; + +@Builder +public record AdminReservationSummaryRes( + Long reservationId, + Long scheduleId, + Long memberId, + ReservationStatus status, + Integer seatCount, + LocalDateTime createdAt, + LocalDateTime expiresAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/response/LotteryReservationCreatedRes.java b/src/main/java/com/back/b2st/domain/reservation/dto/response/LotteryReservationCreatedRes.java new file mode 100644 index 000000000..669bef46d --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/dto/response/LotteryReservationCreatedRes.java @@ -0,0 +1,12 @@ +package com.back.b2st.domain.reservation.dto.response; + +import com.back.b2st.domain.reservation.entity.Reservation; + +public record LotteryReservationCreatedRes( + Long reservationId, + Long scheduleId +) { + public static LotteryReservationCreatedRes from(Reservation reservation) { + return new LotteryReservationCreatedRes(reservation.getId(), reservation.getScheduleId()); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailRes.java b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailRes.java index 7b7f41479..788c1a3cf 100644 --- a/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailRes.java +++ b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailRes.java @@ -5,8 +5,7 @@ public record ReservationDetailRes( Long reservationId, String status, - PerformanceInfo performance, - SeatInfo seat + PerformanceInfo performance ) { public record PerformanceInfo( diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailWithPaymentRes.java b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailWithPaymentRes.java index b85f5857c..1db7fc4db 100644 --- a/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailWithPaymentRes.java +++ b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationDetailWithPaymentRes.java @@ -1,9 +1,12 @@ package com.back.b2st.domain.reservation.dto.response; +import java.util.List; + import com.back.b2st.domain.payment.dto.response.PaymentConfirmRes; public record ReservationDetailWithPaymentRes( ReservationDetailRes reservation, + List seats, PaymentConfirmRes payment ) { } \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationRes.java b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationRes.java new file mode 100644 index 000000000..306da1e12 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationRes.java @@ -0,0 +1,19 @@ +package com.back.b2st.domain.reservation.dto.response; + +import java.time.LocalDateTime; + +public record ReservationRes( + Long reservationId, + String status, + PerformanceInfo performance +) { + public record PerformanceInfo( + Long performanceId, + Long performanceScheduleId, + String title, + String category, + LocalDateTime startDate, + LocalDateTime startAt + ) { + } +} diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationSeatInfo.java b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationSeatInfo.java new file mode 100644 index 000000000..162fce58c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/dto/response/ReservationSeatInfo.java @@ -0,0 +1,10 @@ +package com.back.b2st.domain.reservation.dto.response; + +public record ReservationSeatInfo( + Long seatId, + Long sectionId, + String sectionName, + String rowLabel, + Integer seatNumber +) { +} diff --git a/src/main/java/com/back/b2st/domain/reservation/dto/response/SeatReservationResult.java b/src/main/java/com/back/b2st/domain/reservation/dto/response/SeatReservationResult.java new file mode 100644 index 000000000..c8ac389e1 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/dto/response/SeatReservationResult.java @@ -0,0 +1,10 @@ +package com.back.b2st.domain.reservation.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record SeatReservationResult( + List scheduleSeatIds, + LocalDateTime expiresAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/entity/Reservation.java b/src/main/java/com/back/b2st/domain/reservation/entity/Reservation.java index 7d51241eb..566df5747 100644 --- a/src/main/java/com/back/b2st/domain/reservation/entity/Reservation.java +++ b/src/main/java/com/back/b2st/domain/reservation/entity/Reservation.java @@ -24,7 +24,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "reservation", indexes = { - @Index(name = "idx_reservation_member", columnList = "member_id") + @Index(name = "idx_reservation_member_schedule", columnList = "member_id, schedule_id"), + @Index(name = "idx_reservation_status_expires", columnList = "status, expires_at") } ) @SequenceGenerator( @@ -45,9 +46,6 @@ public class Reservation extends BaseEntity { @Column(name = "member_id", nullable = false) private Long memberId; // 예매자 FK - @Column(name = "seat_id", nullable = false) - private Long seatId; // 좌석 FK - @Column(name = "canceled_at") private LocalDateTime canceledAt; @@ -65,12 +63,10 @@ public class Reservation extends BaseEntity { public Reservation( Long scheduleId, Long memberId, - Long seatId, LocalDateTime expiresAt ) { this.scheduleId = scheduleId; this.memberId = memberId; - this.seatId = seatId; this.expiresAt = expiresAt; this.status = ReservationStatus.PENDING; } diff --git a/src/main/java/com/back/b2st/domain/reservation/entity/ReservationSeat.java b/src/main/java/com/back/b2st/domain/reservation/entity/ReservationSeat.java new file mode 100644 index 000000000..2f02a284e --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/entity/ReservationSeat.java @@ -0,0 +1,43 @@ +package com.back.b2st.domain.reservation.entity; + +import com.back.b2st.global.jpa.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "reservation_seat") +@SequenceGenerator( + name = "reservation_seat_id_gen", + sequenceName = "reservation_seat_seq", + allocationSize = 50 +) +public class ReservationSeat extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "reservation_seat_id_gen") + private Long id; + + @Column(nullable = false) + private Long reservationId; + + @Column(nullable = false) + private Long scheduleSeatId; + + @Builder + public ReservationSeat(Long reservationId, Long scheduleSeatId) { + this.reservationId = reservationId; + this.scheduleSeatId = scheduleSeatId; + } +} diff --git a/src/main/java/com/back/b2st/domain/reservation/entity/ReservationStatus.java b/src/main/java/com/back/b2st/domain/reservation/entity/ReservationStatus.java index e6e11ed10..3338ddcaf 100644 --- a/src/main/java/com/back/b2st/domain/reservation/entity/ReservationStatus.java +++ b/src/main/java/com/back/b2st/domain/reservation/entity/ReservationStatus.java @@ -17,7 +17,7 @@ public boolean canComplete() { } public boolean canCancel() { - return this == PENDING; + return this == PENDING || this == COMPLETED; } public boolean canExpire() { diff --git a/src/main/java/com/back/b2st/domain/reservation/error/ReservationErrorCode.java b/src/main/java/com/back/b2st/domain/reservation/error/ReservationErrorCode.java index 100de216c..e95e4375d 100644 --- a/src/main/java/com/back/b2st/domain/reservation/error/ReservationErrorCode.java +++ b/src/main/java/com/back/b2st/domain/reservation/error/ReservationErrorCode.java @@ -14,6 +14,7 @@ public enum ReservationErrorCode implements ErrorCode { /* ===== 예매 조회/권한 ===== */ RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R007", "예매내역이 없습니다."), RESERVATION_FORBIDDEN(HttpStatus.FORBIDDEN, "R008", "해당 예매에 대한 권한이 없습니다."), + RESERVATION_SCHEDULE_MISMATCH(HttpStatus.BAD_REQUEST, "RS002", "예매 회차 정보가 일치하지 않습니다."), /* ===== 예매 상태 ===== */ RESERVATION_ALREADY_COMPLETED(HttpStatus.CONFLICT, "R009", "이미 결제가 완료된 예매입니다."), @@ -21,7 +22,10 @@ public enum ReservationErrorCode implements ErrorCode { INVALID_RESERVATION_STATUS(HttpStatus.CONFLICT, "R011", "현재 예매 상태에서는 요청을 수행할 수 없습니다."), /* ===== 중복/충돌 ===== */ - RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "R015", "이미 해당 좌석에 대한 예매가 존재합니다."); + RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "R015", "이미 해당 좌석에 대한 예매가 존재합니다."), + + /* ===== 좌석 선택 정책 ===== */ + INVALID_SEAT_COUNT(HttpStatus.BAD_REQUEST, "R016", "선착순 예매는 좌석 1개만 선택할 수 있습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/back/b2st/domain/reservation/metrics/ReservationMetrics.java b/src/main/java/com/back/b2st/domain/reservation/metrics/ReservationMetrics.java new file mode 100644 index 000000000..dceff2811 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/metrics/ReservationMetrics.java @@ -0,0 +1,115 @@ +package com.back.b2st.domain.reservation.metrics; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; + +@Component +public class ReservationMetrics { + private final MeterRegistry registry; + private final AtomicInteger pendingCount = new AtomicInteger(0); + // Counters + private final Counter reservationCreatedCounter; + private final Counter reservationCompletedCounter; + private final Counter reservationFailedCounter; + private final Counter reservationExpiredCounter; + private final Counter reservationCancelledCounter; + // Timer + private final Timer reservationCreationTimer; + + public ReservationMetrics(MeterRegistry registry) { + this.registry = registry; + + // 예약 생성 + this.reservationCreatedCounter = Counter.builder("reservation_total") + .tag("action", "created") + .description("예약 생성 횟수") + .register(registry); + + // 예약 완료 + this.reservationCompletedCounter = Counter.builder("reservation_total") + .tag("action", "completed") + .description("예약 완료 횟수") + .register(registry); + + // 예약 실패 + this.reservationFailedCounter = Counter.builder("reservation_total") + .tag("action", "failed") + .description("예약 실패 횟수") + .register(registry); + + // 예약 만료 + this.reservationExpiredCounter = Counter.builder("reservation_total") + .tag("action", "expired") + .description("예약 만료 횟수") + .register(registry); + + // 예약 취소 + this.reservationCancelledCounter = Counter.builder("reservation_total") + .tag("action", "cancelled") + .description("예약 취소 횟수") + .register(registry); + + // 현재 PENDING 예약 수 + Gauge.builder("reservation_pending_count", pendingCount, AtomicInteger::get) + .description("현재 PENDING 상태 예약 수") + .register(registry); + + // 예약 생성 처리 시간 + this.reservationCreationTimer = Timer.builder("reservation_creation_duration") + .description("예약 생성 처리 시간") + .register(registry); + } + + /** 예약 생성 기록 */ + public void recordCreated() { + reservationCreatedCounter.increment(); + pendingCount.incrementAndGet(); + } + + /** 예약 완료 기록 */ + public void recordCompleted() { + reservationCompletedCounter.increment(); + pendingCount.decrementAndGet(); + } + + /** 예약 실패 기록 */ + public void recordFailed() { + reservationFailedCounter.increment(); + pendingCount.decrementAndGet(); + } + + /** 예약 만료 기록 (배치용) */ + public void recordExpired(int count) { + for (int i = 0; i < count; i++) { + reservationExpiredCounter.increment(); + } + pendingCount.addAndGet(-count); + } + + /** 예약 취소 기록 */ + public void recordCancelled() { + reservationCancelledCounter.increment(); + pendingCount.decrementAndGet(); + } + + /** 예약 생성 처리 시간 측정 시작 */ + public Timer.Sample startCreationTimer() { + return Timer.start(registry); + } + + /** 예약 생성 처리 시간 측정 종료 */ + public void stopCreationTimer(Timer.Sample sample) { + sample.stop(reservationCreationTimer); + } + + /** PENDING 예약 수 설정 (배치용) */ + public void setPendingCount(int count) { + pendingCount.set(count); + } +} diff --git a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepository.java b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepository.java index 567f38bed..827e0e286 100644 --- a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; @@ -19,25 +21,13 @@ @Repository public interface ReservationRepository extends JpaRepository, ReservationRepositoryCustom { - /** === 로그인 유저 예매 전체 조회 === */ - List findAllByMemberId(Long memberId); - - /** 활성 PENDING 존재 여부(만료된 PENDING은 제외) */ - boolean existsByScheduleIdAndSeatIdAndStatusAndExpiresAtAfter( - Long scheduleId, - Long seatId, - ReservationStatus status, - LocalDateTime now - ); - - /** COMPLETED 존재 여부(완료는 언제나 중복 방지) */ - boolean existsByScheduleIdAndSeatIdAndStatus( + Optional findTopByMemberIdAndScheduleIdAndStatusOrderByIdDesc( + Long memberId, Long scheduleId, - Long seatId, ReservationStatus status ); - /** 상태 변경 경쟁(complete/fail/expire) 직렬화를 위한 락 조회 */ + /** 락 조회 */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT r FROM Reservation r WHERE r.id = :reservationId") Optional findByIdWithLock(@Param("reservationId") Long reservationId); @@ -52,6 +42,15 @@ boolean existsByScheduleIdAndSeatIdAndStatus( """) List findExpiredPendingIds(@Param("pending") ReservationStatus pending, @Param("now") LocalDateTime now); + @Query(""" + select r.id + from Reservation r + where r.scheduleId in :scheduleIds + """) + List findIdsByScheduleIdIn(@Param("scheduleIds") List scheduleIds); + + void deleteAllByScheduleIdIn(List scheduleIds); + /** PENDING -> EXPIRED 일괄 처리 */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" @@ -65,4 +64,20 @@ int bulkExpirePendingByIds( @Param("pending") ReservationStatus pending, @Param("expired") ReservationStatus expired ); + + @Query(""" + select r + from Reservation r + where r.status = :status + and (:scheduleId is null or r.scheduleId = :scheduleId) + and (:memberId is null or r.memberId = :memberId) + order by r.id desc + """) + Page findByStatusWithOptionalFilters( + @Param("status") ReservationStatus status, + @Param("scheduleId") Long scheduleId, + @Param("memberId") Long memberId, + Pageable pageable + ); + } diff --git a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryCustom.java b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryCustom.java index 241b6cf6e..944181212 100644 --- a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryCustom.java +++ b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryCustom.java @@ -1,12 +1,32 @@ package com.back.b2st.domain.reservation.repository; +import java.time.LocalDateTime; import java.util.List; import com.back.b2st.domain.reservation.dto.response.ReservationDetailRes; +import com.back.b2st.domain.reservation.dto.response.ReservationRes; public interface ReservationRepositoryCustom { - List findMyReservationDetails(Long memberId); + /** 예매 목록 조회 */ + List findMyReservations(Long memberId); + /** 특정 예매 상세 조회 (본인 소유 검증 포함) */ ReservationDetailRes findReservationDetail(Long reservationId, Long memberId); + + /** 특정 예매 상세 조회 (소유 검증 없음) */ + ReservationDetailRes findReservationDetail(Long reservationId); + + /** 해당 좌석에 대해 이미 완료된 예매(COMPLETED)가 존재하는지 확인 */ + boolean existsCompletedByScheduleSeat( + Long scheduleId, + Long scheduleSeatId + ); + + /** 해당 좌석에 대해 아직 유효한 PENDING 예매가 존재하는지 확인 */ + boolean existsActivePendingByScheduleSeat( + Long scheduleId, + Long scheduleSeatId, + LocalDateTime now + ); } diff --git a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryImpl.java b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryImpl.java index bae7836c3..9d33ebd70 100644 --- a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryImpl.java +++ b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationRepositoryImpl.java @@ -3,13 +3,16 @@ import static com.back.b2st.domain.performance.entity.QPerformance.*; import static com.back.b2st.domain.performanceschedule.entity.QPerformanceSchedule.*; import static com.back.b2st.domain.reservation.entity.QReservation.*; -import static com.back.b2st.domain.seat.seat.entity.QSeat.*; +import static com.back.b2st.domain.reservation.entity.QReservationSeat.*; +import java.time.LocalDateTime; import java.util.List; import org.springframework.stereotype.Repository; import com.back.b2st.domain.reservation.dto.response.ReservationDetailRes; +import com.back.b2st.domain.reservation.dto.response.ReservationRes; +import com.back.b2st.domain.reservation.entity.ReservationStatus; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -37,14 +40,6 @@ public ReservationDetailRes findReservationDetail(Long reservationId, Long membe performance.category, performance.startDate, performanceSchedule.startAt - ), - Projections.constructor( - ReservationDetailRes.SeatInfo.class, - seat.id, - seat.sectionId, - seat.sectionName, - seat.rowLabel, - seat.seatNumber ) ) ) @@ -53,8 +48,6 @@ public ReservationDetailRes findReservationDetail(Long reservationId, Long membe .on(reservation.scheduleId.eq(performanceSchedule.performanceScheduleId)) .join(performance) .on(performanceSchedule.performance.eq(performance)) - .join(seat) - .on(reservation.seatId.eq(seat.id)) .where( reservation.id.eq(reservationId), reservation.memberId.eq(memberId) @@ -63,7 +56,7 @@ public ReservationDetailRes findReservationDetail(Long reservationId, Long membe } @Override - public List findMyReservationDetails(Long memberId) { + public ReservationDetailRes findReservationDetail(Long reservationId) { return queryFactory .select( Projections.constructor( @@ -78,14 +71,34 @@ public List findMyReservationDetails(Long memberId) { performance.category, performance.startDate, performanceSchedule.startAt - ), + ) + ) + ) + .from(reservation) + .join(performanceSchedule) + .on(reservation.scheduleId.eq(performanceSchedule.performanceScheduleId)) + .join(performance) + .on(performanceSchedule.performance.eq(performance)) + .where(reservation.id.eq(reservationId)) + .fetchOne(); + } + + @Override + public List findMyReservations(Long memberId) { + return queryFactory + .select( + Projections.constructor( + ReservationRes.class, + reservation.id, + reservation.status.stringValue(), Projections.constructor( - ReservationDetailRes.SeatInfo.class, - seat.id, - seat.sectionId, - seat.sectionName, - seat.rowLabel, - seat.seatNumber + ReservationRes.PerformanceInfo.class, + performance.performanceId, + performanceSchedule.performanceScheduleId, + performance.title, + performance.category, + performance.startDate, + performanceSchedule.startAt ) ) ) @@ -94,10 +107,56 @@ public List findMyReservationDetails(Long memberId) { .on(reservation.scheduleId.eq(performanceSchedule.performanceScheduleId)) .join(performance) .on(performanceSchedule.performance.eq(performance)) - .join(seat) - .on(reservation.seatId.eq(seat.id)) - .where(reservation.memberId.eq(memberId)) + .where( + reservation.memberId.eq(memberId), + reservation.status.in( + ReservationStatus.COMPLETED, + ReservationStatus.CANCELED + ) + ) .orderBy(reservation.createdAt.desc()) .fetch(); } + + @Override + public boolean existsCompletedByScheduleSeat( + Long scheduleId, + Long scheduleSeatId + ) { + Integer result = queryFactory + .selectOne() + .from(reservation) + .join(reservationSeat) + .on(reservationSeat.reservationId.eq(reservation.id)) + .where( + reservation.scheduleId.eq(scheduleId), + reservationSeat.scheduleSeatId.eq(scheduleSeatId), + reservation.status.eq(ReservationStatus.COMPLETED) + ) + .fetchFirst(); + + return result != null; + } + + @Override + public boolean existsActivePendingByScheduleSeat( + Long scheduleId, + Long scheduleSeatId, + LocalDateTime now + ) { + Integer result = queryFactory + .selectOne() + .from(reservation) + .join(reservationSeat) + .on(reservationSeat.reservationId.eq(reservation.id)) + .where( + reservation.scheduleId.eq(scheduleId), + reservationSeat.scheduleSeatId.eq(scheduleSeatId), + reservation.status.eq(ReservationStatus.PENDING), + reservation.expiresAt.gt(now) + ) + .fetchFirst(); + + return result != null; + } } diff --git a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepository.java b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepository.java new file mode 100644 index 000000000..a77fbe200 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepository.java @@ -0,0 +1,21 @@ +package com.back.b2st.domain.reservation.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.back.b2st.domain.reservation.entity.ReservationSeat; + +@Repository +public interface ReservationSeatRepository + extends JpaRepository, ReservationSeatRepositoryCustom { + + List findByReservationId(Long reservationId); + + boolean existsByReservationId(Long reservationId); + + int countByReservationId(Long reservationId); + + void deleteAllByReservationIdIn(List reservationIds); +} diff --git a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepositoryCustom.java b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepositoryCustom.java new file mode 100644 index 000000000..32a09575e --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.back.b2st.domain.reservation.repository; + +import java.util.List; + +import com.back.b2st.domain.reservation.dto.response.ReservationSeatInfo; + +public interface ReservationSeatRepositoryCustom { + + List findSeatInfos(Long reservationId); +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepositoryImpl.java b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepositoryImpl.java new file mode 100644 index 000000000..961180632 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/repository/ReservationSeatRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.back.b2st.domain.reservation.repository; + +import static com.back.b2st.domain.reservation.entity.QReservationSeat.*; +import static com.back.b2st.domain.scheduleseat.entity.QScheduleSeat.*; +import static com.back.b2st.domain.seat.seat.entity.QSeat.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.back.b2st.domain.reservation.dto.response.ReservationSeatInfo; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ReservationSeatRepositoryImpl implements ReservationSeatRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findSeatInfos(Long reservationId) { + return queryFactory + .select( + Projections.constructor( + ReservationSeatInfo.class, + seat.id, + seat.sectionId, + seat.sectionName, + seat.rowLabel, + seat.seatNumber + ) + ) + .from(reservationSeat) + .join(scheduleSeat) + .on(reservationSeat.scheduleSeatId.eq(scheduleSeat.id)) + .join(seat) + .on(scheduleSeat.seatId.eq(seat.id)) + .where(reservationSeat.reservationId.eq(reservationId)) + .fetch(); + } + +} diff --git a/src/main/java/com/back/b2st/domain/reservation/service/AdminReservationService.java b/src/main/java/com/back/b2st/domain/reservation/service/AdminReservationService.java new file mode 100644 index 000000000..b7f12e5f5 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/service/AdminReservationService.java @@ -0,0 +1,106 @@ +package com.back.b2st.domain.reservation.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.payment.dto.response.PaymentConfirmRes; +import com.back.b2st.domain.payment.service.PaymentViewService; +import com.back.b2st.domain.reservation.dto.response.AdminReservationSummaryRes; +import com.back.b2st.domain.reservation.dto.response.ReservationDetailRes; +import com.back.b2st.domain.reservation.dto.response.ReservationDetailWithPaymentRes; +import com.back.b2st.domain.reservation.dto.response.ReservationSeatInfo; +import com.back.b2st.domain.reservation.entity.Reservation; +import com.back.b2st.domain.reservation.entity.ReservationStatus; +import com.back.b2st.domain.reservation.error.ReservationErrorCode; +import com.back.b2st.domain.reservation.repository.ReservationRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; +import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminReservationService { + + private final ReservationRepository reservationRepository; + private final ReservationSeatRepository reservationSeatRepository; + private final PaymentViewService paymentViewService; + private final ScheduleSeatStateService scheduleSeatStateService; + + /** === 관리자 예매 상태별 조회 === */ + public Page getReservationsByStatus( + ReservationStatus status, + Long scheduleId, + Long memberId, + Pageable pageable + ) { + Page page = + reservationRepository.findByStatusWithOptionalFilters(status, scheduleId, memberId, pageable); + + return page.map(r -> AdminReservationSummaryRes.builder() + .reservationId(r.getId()) + .scheduleId(r.getScheduleId()) + .memberId(r.getMemberId()) + .status(r.getStatus()) + .seatCount(reservationSeatRepository.countByReservationId(r.getId())) + .createdAt(r.getCreatedAt()) + .expiresAt(r.getExpiresAt()) + .build() + ); + } + + /** === 관리자 예매 상세 조회 === */ + public ReservationDetailWithPaymentRes getReservationDetail(Long reservationId) { + + Reservation entity = reservationRepository.findById(reservationId) + .orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + + Long memberId = entity.getMemberId(); + + ReservationDetailRes reservation = + reservationRepository.findReservationDetail(reservationId, memberId); + + if (reservation == null) { + throw new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND); + } + + List seats = + reservationSeatRepository.findSeatInfos(reservationId); + + PaymentConfirmRes payment = + paymentViewService.getByReservationId(reservationId, memberId); + + return new ReservationDetailWithPaymentRes(reservation, seats, payment); + } + + /** === 관리자 예매 강제 취소 + 좌석 원복 === */ + @Transactional + public void forceCancel(Long reservationId) { + + Reservation reservation = reservationRepository.findById(reservationId) + .orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + + if (reservation.getStatus() == ReservationStatus.CANCELED) { + return; + } + + reservation.cancel(LocalDateTime.now()); + + List seatInfos = + reservationSeatRepository.findSeatInfos(reservationId); + + for (ReservationSeatInfo seatInfo : seatInfos) { + scheduleSeatStateService.forceToAvailable( + reservation.getScheduleId(), + seatInfo.seatId() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/reservation/service/LotteryReservationService.java b/src/main/java/com/back/b2st/domain/reservation/service/LotteryReservationService.java new file mode 100644 index 000000000..eb159352c --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/service/LotteryReservationService.java @@ -0,0 +1,98 @@ +package com.back.b2st.domain.reservation.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.reservation.dto.response.LotteryReservationCreatedRes; +import com.back.b2st.domain.reservation.entity.Reservation; +import com.back.b2st.domain.reservation.entity.ReservationSeat; +import com.back.b2st.domain.reservation.entity.ReservationStatus; +import com.back.b2st.domain.reservation.repository.ReservationRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; +import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.domain.scheduleseat.error.ScheduleSeatErrorCode; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LotteryReservationService { + + private final ReservationRepository reservationRepository; + private final ReservationSeatRepository reservationSeatRepository; + private final ScheduleSeatRepository scheduleSeatRepository; + + /** === 추첨 예매 생성 (결제 완료 기준) === */ + @Transactional + public LotteryReservationCreatedRes createCompletedReservation(Long memberId, Long scheduleId) { + Reservation reservation = getOrCreateCompletedReservation(memberId, scheduleId); + return LotteryReservationCreatedRes.from(reservation); + } + + @Transactional + public Reservation getOrCreateCompletedReservation(Long memberId, Long scheduleId) { + LocalDateTime now = LocalDateTime.now(); + + Optional completed = + reservationRepository.findTopByMemberIdAndScheduleIdAndStatusOrderByIdDesc( + memberId, scheduleId, ReservationStatus.COMPLETED + ); + + if (completed.isPresent()) { + return completed.get(); + } + + Optional pending = + reservationRepository.findTopByMemberIdAndScheduleIdAndStatusOrderByIdDesc( + memberId, scheduleId, ReservationStatus.PENDING + ); + + if (pending.isPresent()) { + Reservation reservation = pending.get(); + reservation.complete(now); + return reservation; + } + + Reservation reservation = Reservation.builder() + .scheduleId(scheduleId) + .memberId(memberId) + .expiresAt(now) + .build(); + reservation.complete(now); + + return reservationRepository.save(reservation); + } + + /** === 추첨 좌석 확정 === */ + @Transactional + public void confirmAssignedSeats(Long reservationId, Long scheduleId, List scheduleSeatIds) { + + // 1. 좌석 상태 AVAILABLE → SOLD (추첨 확정) + int updated = scheduleSeatRepository.updateStatusToSoldByScheduleSeatIds( + scheduleId, + scheduleSeatIds, + SeatStatus.AVAILABLE, + SeatStatus.SOLD + ); + + if (updated != scheduleSeatIds.size()) { + throw new BusinessException(ScheduleSeatErrorCode.SEAT_ALREADY_SOLD); + } + + // 2. 예매-좌석 매핑 생성 + for (Long scheduleSeatId : scheduleSeatIds) { + reservationSeatRepository.save( + ReservationSeat.builder() + .reservationId(reservationId) + .scheduleSeatId(scheduleSeatId) + .build() + ); + } + } +} diff --git a/src/main/java/com/back/b2st/domain/reservation/service/ReservationSeatManager.java b/src/main/java/com/back/b2st/domain/reservation/service/ReservationSeatManager.java new file mode 100644 index 000000000..5948637f9 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/service/ReservationSeatManager.java @@ -0,0 +1,124 @@ +package com.back.b2st.domain.reservation.service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.reservation.dto.response.SeatReservationResult; +import com.back.b2st.domain.reservation.entity.ReservationSeat; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.domain.scheduleseat.service.ScheduleSeatService; +import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReservationSeatManager { + + private final ReservationSeatRepository reservationSeatRepository; + private final ScheduleSeatRepository scheduleSeatRepository; + + private final ScheduleSeatService scheduleSeatService; + private final ScheduleSeatStateService scheduleSeatStateService; + + /** === 예매 전 좌석 검사 === */ + @Transactional(readOnly = true) + public SeatReservationResult prepareSeatReservation( + Long scheduleId, + List seatIds, + Long memberId + ) { + LocalDateTime expiresAt = null; + List scheduleSeatIds = new ArrayList<>(); + + for (Long seatId : seatIds) { + ScheduleSeat seat = + scheduleSeatService.validateAndGetAttachableSeat( + scheduleId, seatId, memberId + ); + + scheduleSeatIds.add(seat.getId()); + + // 만료 시각은 가장 빠른 HOLD 기준 + if (expiresAt == null || seat.getHoldExpiredAt().isBefore(expiresAt)) { + expiresAt = seat.getHoldExpiredAt(); + } + } + + return new SeatReservationResult( + scheduleSeatIds, + expiresAt + ); + } + + /** === 예매용 좌석 저장 === */ + @Transactional + public void attachSeats( + Long reservationId, + List scheduleSeatIds + ) { + for (Long scheduleSeatId : scheduleSeatIds) { + reservationSeatRepository.save( + ReservationSeat.builder() + .reservationId(reservationId) + .scheduleSeatId(scheduleSeatId) + .build() + ); + } + } + + /** === 예매에 포함된 좌석 HOLD 해제 === */ + @Transactional + public void releaseAllSeats(Long reservationId) { + reservationSeatRepository.findByReservationId(reservationId) + .forEach(rs -> { + ScheduleSeat scheduleSeat = + scheduleSeatRepository.findById(rs.getScheduleSeatId()) + .orElseThrow(); + + scheduleSeatStateService.releaseHold( + scheduleSeat.getScheduleId(), + scheduleSeat.getSeatId() + ); + }); + } + + /** === 예매에 포함된 모든 좌석 HOLD 해제 === */ + @Transactional + public void releaseForceAllSeats(Long reservationId) { + reservationSeatRepository.findByReservationId(reservationId) + .forEach(rs -> { + ScheduleSeat scheduleSeat = + scheduleSeatRepository.findById(rs.getScheduleSeatId()) + .orElseThrow(); + + scheduleSeatStateService.releaseForceHold( + scheduleSeat.getScheduleId(), + scheduleSeat.getSeatId() + ); + }); + } + + /** === 예매에 포함된 모든 좌석 SOLD 처리 === */ + @Transactional + public void confirmAllSeats(Long reservationId) { + + reservationSeatRepository.findByReservationId(reservationId) + .forEach(rs -> { + ScheduleSeat scheduleSeat = + scheduleSeatRepository.findById(rs.getScheduleSeatId()) + .orElseThrow(); + + scheduleSeatStateService.confirmHold( + scheduleSeat.getScheduleId(), + scheduleSeat.getSeatId() + ); + }); + } +} diff --git a/src/main/java/com/back/b2st/domain/reservation/service/ReservationService.java b/src/main/java/com/back/b2st/domain/reservation/service/ReservationService.java index a27a86adc..3532b5407 100644 --- a/src/main/java/com/back/b2st/domain/reservation/service/ReservationService.java +++ b/src/main/java/com/back/b2st/domain/reservation/service/ReservationService.java @@ -3,19 +3,17 @@ import java.time.LocalDateTime; import java.util.List; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.back.b2st.domain.reservation.dto.request.ReservationReq; import com.back.b2st.domain.reservation.dto.response.ReservationCreateRes; -import com.back.b2st.domain.reservation.dto.response.ReservationDetailRes; +import com.back.b2st.domain.reservation.dto.response.SeatReservationResult; import com.back.b2st.domain.reservation.entity.Reservation; import com.back.b2st.domain.reservation.entity.ReservationStatus; import com.back.b2st.domain.reservation.error.ReservationErrorCode; import com.back.b2st.domain.reservation.repository.ReservationRepository; -import com.back.b2st.domain.scheduleseat.service.ScheduleSeatStateService; -import com.back.b2st.domain.scheduleseat.service.SeatHoldTokenService; +import com.back.b2st.domain.ticket.service.TicketService; import com.back.b2st.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -25,55 +23,46 @@ public class ReservationService { private final ReservationRepository reservationRepository; + private final ReservationSeatManager reservationSeatManager; - private final SeatHoldTokenService seatHoldTokenService; - private final ScheduleSeatStateService scheduleSeatStateService; + private final TicketService ticketService; /** === 예매 생성(결제 시작) === */ @Transactional public ReservationCreateRes createReservation(Long memberId, ReservationReq request) { Long scheduleId = request.scheduleId(); - Long seatId = request.seatId(); + List seatIds = request.seatIds(); - LocalDateTime now = LocalDateTime.now(); - - // COMPLETED는 무조건 중복 방지 - if (reservationRepository.existsByScheduleIdAndSeatIdAndStatus( - scheduleId, - seatId, - ReservationStatus.COMPLETED - )) { - throw new BusinessException(ReservationErrorCode.RESERVATION_ALREADY_EXISTS); - } + // 1. 좌석은 1자리씩 예매 가능 + validateReservationPolicy(seatIds); - // PENDING은 "활성(PENDING && expiresAt > now)"만 중복 방지 - if (reservationRepository.existsByScheduleIdAndSeatIdAndStatusAndExpiresAtAfter( - scheduleId, - seatId, - ReservationStatus.PENDING, - now - )) { - throw new BusinessException(ReservationErrorCode.RESERVATION_ALREADY_EXISTS); - } + // 2. 좌석 검사 + 만료시각 확보 + SeatReservationResult seatResult = + reservationSeatManager.prepareSeatReservation( + scheduleId, seatIds, memberId + ); - // 2) HOLD 소유권 검증 (Redis) - seatHoldTokenService.validateOwnership(scheduleId, seatId, memberId); + // 3. 예매 중복 검증 + validateReservationDuplicate(scheduleId, seatResult.scheduleSeatIds()); - // 3) DB 좌석 상태 검증 (HOLD + 만료) - scheduleSeatStateService.validateHoldState(scheduleId, seatId); + // 4. Reservation(PENDING) 생성 + Reservation reservation = + reservationRepository.save( + request.toEntity(memberId, seatResult.expiresAt())); - // 4) 예매 만료시각(expiresAt)은 좌석 holdExpiredAt과 동일하게(불일치 방지) - LocalDateTime expiresAt = scheduleSeatStateService.getHoldExpiredAtOrThrow(scheduleId, seatId); + // 5. 좌석 귀속 + reservationSeatManager.attachSeats( + reservation.getId(), + seatResult.scheduleSeatIds() + ); - // 4) Reservation(PENDING) 생성 - Reservation reservation = request.toEntity(memberId, expiresAt); + return ReservationCreateRes.from(reservation); + } - try { - return ReservationCreateRes.from(reservationRepository.save(reservation)); - } catch (DataIntegrityViolationException e) { - // TODO: 추후 DB partial unique? - throw new BusinessException(ReservationErrorCode.RESERVATION_ALREADY_EXISTS); + private static void validateReservationPolicy(List seatIds) { + if (seatIds.size() != 1) { + throw new BusinessException(ReservationErrorCode.INVALID_SEAT_COUNT); } } @@ -98,13 +87,8 @@ public void failReservation(Long reservationId) { // PENDING -> FAILED reservation.fail(); - // 좌석 복구 (HOLD → AVAILABLE) - scheduleSeatStateService.changeToAvailable( - reservation.getScheduleId(), - reservation.getSeatId() - ); - - seatHoldTokenService.remove(reservation.getScheduleId(), reservation.getSeatId()); + // 좌석 상태 복구 (HOLD → AVAILABLE) + reservationSeatManager.releaseAllSeats(reservationId); } /** === 예매 취소 (일단 결제 완료 시 취소 불가) === */ @@ -117,18 +101,36 @@ public void cancelReservation(Long reservationId, Long memberId) { throw new BusinessException(ReservationErrorCode.INVALID_RESERVATION_STATUS); } + //TODO: 환불, 결제 취소 로직 + + // 1) 티켓 취소 (ISSUED -> CANCELED) + ticketService.cancelTicketsByReservation(reservationId, memberId); + + // 2) 예매 취소 (COMPLETED -> CANCELLED) reservation.cancel(LocalDateTime.now()); - // 좌석 상태 복구 (HOLD → AVAILABLE) TODO: 일단 HOLD만 가능 - scheduleSeatStateService.changeToAvailable( - reservation.getScheduleId(), - reservation.getSeatId() - ); + // 3) 좌석 해제 (SOLD -> AVAILABLE) + reservationSeatManager.releaseForceAllSeats(reservationId); + } - seatHoldTokenService.remove(reservation.getScheduleId(), reservation.getSeatId()); + /** === PENDING 만료 배치 처리 (스케줄러) === */ + @Transactional + public int expirePendingReservationsBatch() { + LocalDateTime now = LocalDateTime.now(); + + List expiredIds = reservationRepository.findExpiredPendingIds(ReservationStatus.PENDING, now); + if (expiredIds.isEmpty()) { + return 0; + } + + return reservationRepository.bulkExpirePendingByIds( + expiredIds, + ReservationStatus.PENDING, + ReservationStatus.EXPIRED + ); } - /** === 예매 만료 === */ + /** === 예매 만료 (일단 안 씀) === */ @Transactional public void expireReservation(Long reservationId) { @@ -145,59 +147,18 @@ public void expireReservation(Long reservationId) { reservation.expire(); // 좌석 상태 복구 (HOLD → AVAILABLE) - scheduleSeatStateService.changeToAvailable( - reservation.getScheduleId(), - reservation.getSeatId() - ); - - seatHoldTokenService.remove(reservation.getScheduleId(), reservation.getSeatId()); + reservationSeatManager.releaseAllSeats(reservationId); } - /** === 예매 조회 === */ - @Transactional(readOnly = true) - public ReservationDetailRes getReservationDetail(Long reservationId, Long memberId) { - - ReservationDetailRes result = - reservationRepository.findReservationDetail(reservationId, memberId); - - if (result == null) { - throw new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND); - } - - return result; - } - - /** === 예매 다건 조회 === */ - @Transactional(readOnly = true) - public List getMyReservationsDetail(Long memberId) { - return reservationRepository.findMyReservationDetails(memberId); - } - - /** === PENDING 만료 배치 처리(스케줄러는 이 메서드만 호출) === */ - @Transactional - public int expirePendingReservationsBatch() { - LocalDateTime now = LocalDateTime.now(); - - List expiredIds = reservationRepository.findExpiredPendingIds(ReservationStatus.PENDING, now); - if (expiredIds.isEmpty()) { - return 0; - } - - return reservationRepository.bulkExpirePendingByIds( - expiredIds, - ReservationStatus.PENDING, - ReservationStatus.EXPIRED - ); - } - - /** === 예매 확정 (결제에서 호출되어야 함) === */ + /** === 예매 확정 (일단 안 씀) === */ + // TODO: 지금 안 씀 @Transactional - @Deprecated public void completeReservation(Long reservationId) { Reservation reservation = getReservationWithLock(reservationId); if (reservation.getStatus() == ReservationStatus.COMPLETED) { + ticketService.ensureTicketsForReservation(reservationId); return; } @@ -208,15 +169,38 @@ public void completeReservation(Long reservationId) { reservation.complete(LocalDateTime.now()); // 좌석 상태 변경 (HOLD → SOLD) - scheduleSeatStateService.changeToSold( - reservation.getScheduleId(), - reservation.getSeatId() - ); + reservationSeatManager.confirmAllSeats(reservationId); + + ticketService.ensureTicketsForReservation(reservationId); + } + + // === 중복 예매 방지 === // + private void validateReservationDuplicate(Long scheduleId, List scheduleSeatIds) { - seatHoldTokenService.remove(reservation.getScheduleId(), reservation.getSeatId()); + LocalDateTime now = LocalDateTime.now(); + + for (Long scheduleSeatId : scheduleSeatIds) { + + // 1. 이미 완료된 예매 존재 + if (reservationRepository.existsCompletedByScheduleSeat( + scheduleId, + scheduleSeatId + )) { + throw new BusinessException(ReservationErrorCode.RESERVATION_ALREADY_EXISTS); + } + + // 2. 살아있는 PENDING 예매 존재 + if (reservationRepository.existsActivePendingByScheduleSeat( + scheduleId, + scheduleSeatId, + now + )) { + throw new BusinessException(ReservationErrorCode.RESERVATION_ALREADY_EXISTS); + } + } } - // === 공통 유틸 === // + // === 공통 유틸 (락) === // private Reservation getReservationWithLock(Long reservationId) { return reservationRepository.findByIdWithLock(reservationId) .orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND)); diff --git a/src/main/java/com/back/b2st/domain/reservation/service/ReservationViewService.java b/src/main/java/com/back/b2st/domain/reservation/service/ReservationViewService.java new file mode 100644 index 000000000..f8633ab8b --- /dev/null +++ b/src/main/java/com/back/b2st/domain/reservation/service/ReservationViewService.java @@ -0,0 +1,129 @@ +package com.back.b2st.domain.reservation.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.payment.dto.response.PaymentConfirmRes; +import com.back.b2st.domain.payment.entity.DomainType; +import com.back.b2st.domain.payment.service.PaymentViewService; +import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBooking; +import com.back.b2st.domain.prereservation.booking.repository.PrereservationBookingRepository; +import com.back.b2st.domain.reservation.dto.response.ReservationDetailRes; +import com.back.b2st.domain.reservation.dto.response.ReservationDetailWithPaymentRes; +import com.back.b2st.domain.reservation.dto.response.ReservationRes; +import com.back.b2st.domain.reservation.dto.response.ReservationSeatInfo; +import com.back.b2st.domain.reservation.error.ReservationErrorCode; +import com.back.b2st.domain.reservation.repository.ReservationRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; +import com.back.b2st.domain.seat.seat.entity.Seat; +import com.back.b2st.domain.seat.seat.repository.SeatRepository; +import com.back.b2st.domain.ticket.entity.Ticket; +import com.back.b2st.domain.ticket.repository.TicketRepository; +import com.back.b2st.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReservationViewService { + + private final ReservationRepository reservationRepository; + private final ReservationSeatRepository reservationSeatRepository; + private final PrereservationBookingRepository prereservationBookingRepository; + private final ScheduleSeatRepository scheduleSeatRepository; + private final SeatRepository seatRepository; + private final PerformanceScheduleRepository performanceScheduleRepository; + private final TicketRepository ticketRepository; + private final PaymentViewService paymentViewService; + + /** === 예매 상세 조회 === */ + public ReservationDetailWithPaymentRes getReservationDetail(Long reservationId, Long memberId) { + // 1) 기본: 본인 예약 상세 + ReservationDetailRes ownedReservation = reservationRepository.findReservationDetail(reservationId, memberId); + if (ownedReservation != null) { + List seats = reservationSeatRepository.findSeatInfos(reservationId); + PaymentConfirmRes payment = paymentViewService.getByReservationId(reservationId, memberId); + return new ReservationDetailWithPaymentRes(ownedReservation, seats, payment); + } + // 2) 티켓 소유자 기반 상세 (교환/양도 등으로 예약자 != 티켓소유자 가능) + List tickets = ticketRepository.findAllByReservationIdAndMemberId(reservationId, memberId); + + // 티켓이 없으면 조회 권한 없음 + if (tickets.isEmpty()) { + throw new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND); + } + + // 2-1) 신청예매: Ticket.reservationId가 prereservationBookingId로 저장된 레거시 케이스 + PrereservationBooking booking = prereservationBookingRepository.findById(reservationId).orElse(null); + if (booking != null) { + ScheduleSeat scheduleSeat = scheduleSeatRepository.findById(booking.getScheduleSeatId()) + .orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + + boolean hasMatchingTicketSeat = tickets.stream() + .anyMatch(ticket -> ticket.getSeatId().equals(scheduleSeat.getSeatId())); + + if (hasMatchingTicketSeat) { + PerformanceSchedule schedule = performanceScheduleRepository.findById(booking.getScheduleId()) + .orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + + Seat seat = seatRepository.findById(scheduleSeat.getSeatId()) + .orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + + ReservationDetailRes prereservation = new ReservationDetailRes( + booking.getId(), + booking.getStatus().name(), + new ReservationDetailRes.PerformanceInfo( + schedule.getPerformance().getPerformanceId(), + schedule.getPerformanceScheduleId(), + schedule.getPerformance().getTitle(), + schedule.getPerformance().getCategory(), + schedule.getPerformance().getStartDate(), + schedule.getStartAt() + ) + ); + + List seats = List.of( + new ReservationSeatInfo( + seat.getId(), + seat.getSectionId(), + seat.getSectionName(), + seat.getRowLabel(), + seat.getSeatNumber() + ) + ); + + PaymentConfirmRes payment = paymentViewService.getByDomain(DomainType.PRERESERVATION, booking.getId(), memberId); + return new ReservationDetailWithPaymentRes(prereservation, seats, payment); + } + } + + // 2-2) 일반 예약: "티켓을 가진 사용자"가 좌석 매칭되는 경우 조회 허용 + ReservationDetailRes reservationByTicket = reservationRepository.findReservationDetail(reservationId); + if (reservationByTicket == null) { + throw new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND); + } + + List seats = reservationSeatRepository.findSeatInfos(reservationId); + boolean hasMatchingTicketSeat = seats.stream() + .anyMatch(seat -> tickets.stream().anyMatch(ticket -> ticket.getSeatId().equals(seat.seatId()))); + + if (!hasMatchingTicketSeat) { + throw new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND); + } + + PaymentConfirmRes payment = paymentViewService.getByReservationId(reservationId, memberId); + return new ReservationDetailWithPaymentRes(reservationByTicket, seats, payment); + } + + /** === 예매 다건 조회 === */ + public List getMyReservations(Long memberId) { + return reservationRepository.findMyReservations(memberId); + } +} diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/controller/AdminScheduleSeatApi.java b/src/main/java/com/back/b2st/domain/scheduleseat/controller/AdminScheduleSeatApi.java new file mode 100644 index 000000000..aa134ab25 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/scheduleseat/controller/AdminScheduleSeatApi.java @@ -0,0 +1,61 @@ +package com.back.b2st.domain.scheduleseat.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.back.b2st.domain.scheduleseat.dto.response.ScheduleSeatViewRes; +import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.global.common.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "회차별 좌석 (관리자)", description = "관리자용 회차 좌석 조회 및 HOLD 해제") +@RequestMapping("/api/admin/schedules/{scheduleId}/seats") +@SecurityRequirement(name = "Authorization") +public interface AdminScheduleSeatApi { + + @Operation( + summary = "회차 좌석 조회 (관리자)", + description = "특정 회차의 좌석 목록을 조회합니다. status가 없으면 전체, 있으면 해당 상태만 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "좌석 조회 성공"), + @ApiResponse(responseCode = "404", description = "회차 정보 없음 (SCHEDULE_NOT_FOUND)") + }) + @GetMapping + BaseResponse> getScheduleSeats( + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + + @Parameter(description = "좌석 상태 (AVAILABLE / HOLD / SOLD)", example = "HOLD") + @RequestParam(required = false) SeatStatus status + ); + + @Operation( + summary = "좌석 HOLD 강제 해제 (관리자)", + description = "HOLD 상태의 좌석을 AVAILABLE 상태로 강제 복구합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "HOLD 해제 성공 (응답 바디 없음)"), + @ApiResponse(responseCode = "404", description = "좌석 정보 없음 (SEAT_NOT_FOUND)") + }) + @PostMapping("/{seatId}/release-hold") + BaseResponse releaseHold( + @Parameter(description = "공연 회차 ID", example = "1") + @PathVariable Long scheduleId, + + @Parameter(description = "좌석 ID", example = "101") + @PathVariable Long seatId + ); + +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/controller/AdminScheduleSeatController.java b/src/main/java/com/back/b2st/domain/scheduleseat/controller/AdminScheduleSeatController.java new file mode 100644 index 000000000..4b8e53acc --- /dev/null +++ b/src/main/java/com/back/b2st/domain/scheduleseat/controller/AdminScheduleSeatController.java @@ -0,0 +1,47 @@ +package com.back.b2st.domain.scheduleseat.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.b2st.domain.scheduleseat.dto.response.ScheduleSeatViewRes; +import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.domain.scheduleseat.service.AdminScheduleSeatService; +import com.back.b2st.global.common.BaseResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/admin/schedules/{scheduleId}/seats") +@RequiredArgsConstructor +public class AdminScheduleSeatController implements AdminScheduleSeatApi { + + private final AdminScheduleSeatService adminScheduleSeatService; + + @GetMapping + public BaseResponse> getScheduleSeats( + @PathVariable Long scheduleId, + @RequestParam(required = false) SeatStatus status + ) { + if (status == null) { + return BaseResponse.success(adminScheduleSeatService.getSeats(scheduleId)); + } + return BaseResponse.success( + adminScheduleSeatService.getSeatsByStatus(scheduleId, status) + ); + } + + @PostMapping("/{seatId}/release-hold") + public BaseResponse releaseHold( + @PathVariable Long scheduleId, + @PathVariable Long seatId + ) { + adminScheduleSeatService.releaseHold(scheduleId, seatId); + return BaseResponse.created(null); + } +} diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatApi.java b/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatApi.java index 7692c81d2..e6f14107f 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatApi.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatApi.java @@ -21,23 +21,13 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag( - name = "좌석", - description = "회차별 좌석 조회 및 좌석 HOLD API" -) +@Tag(name = "좌석", description = "회차 좌석 조회 및 좌석 HOLD") @RequestMapping("/api/schedules") public interface ScheduleSeatApi { - /* ================================================== - * 회차별 좌석 조회 (전체 / 상태별) - * ================================================== */ @Operation( - summary = "회차별 좌석 조회 (전체/상태별)", - description = """ - 특정 회차의 좌석 목록을 조회합니다. - - status 미지정: 전체 좌석 조회 (ScheduleSeatService.getSeats) - - status 지정: 해당 상태 좌석만 조회 (ScheduleSeatService.getSeatsByStatus) - """ + summary = "회차 좌석 조회", + description = "특정 회차의 좌석 목록을 조회합니다. status가 없으면 전체, 있으면 해당 상태만 조회합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "좌석 조회 성공"), @@ -48,52 +38,24 @@ BaseResponse> getScheduleSeats( @Parameter(description = "공연 회차 ID", example = "1") @PathVariable Long scheduleId, - @Parameter( - description = "좌석 상태 (AVAILABLE / HOLD / SOLD)", - example = "AVAILABLE", - required = false - ) + @Parameter(description = "좌석 상태 (AVAILABLE / HOLD / SOLD)", example = "AVAILABLE") @RequestParam(required = false) SeatStatus status ); - /* ================================================== - * 좌석 HOLD (AVAILABLE → HOLD) - * ================================================== */ @Operation( summary = "좌석 HOLD", - description = """ - 좌석을 HOLD 상태로 변경합니다. - - 로그인 사용자만 가능 - - Redis 분산 락(3초 TTL) 획득 후 처리 - - DB 좌석 상태: AVAILABLE → HOLD - - Redis HOLD 소유권 토큰 저장(5분 TTL) - - 주의) - - 이 API는 HOLD '소유권 검증'을 수행하지 않습니다. - (SEAT_HOLD_EXPIRED / SEAT_HOLD_FORBIDDEN은 이 API에서 발생하지 않음) - """ + description = "좌석을 HOLD 상태로 변경합니다. (AVAILABLE → HOLD)" ) + @SecurityRequirement(name = "Authorization") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "좌석 HOLD 성공"), + @ApiResponse(responseCode = "201", description = "좌석 HOLD 성공"), @ApiResponse(responseCode = "401", description = "인증 실패 (로그인 필요)"), - @ApiResponse( - responseCode = "404", - description = """ - 좌석 정보 없음 - - SEAT_NOT_FOUND - """ - ), + @ApiResponse(responseCode = "404", description = "좌석 정보 없음 (SEAT_NOT_FOUND)"), @ApiResponse( responseCode = "409", - description = """ - 좌석 상태 충돌 / 락 획득 실패 - - SEAT_ALREADY_HOLD (이미 HOLD 상태) - - SEAT_ALREADY_SOLD (이미 SOLD 상태) - - SEAT_LOCK_FAILED (락 획득 실패) - """ + description = "상태 충돌/락 실패 (SEAT_ALREADY_HOLD / SEAT_ALREADY_SOLD / SEAT_LOCK_FAILED)" ) }) - @SecurityRequirement(name = "Authorization") @PostMapping("/{scheduleId}/seats/{seatId}/hold") BaseResponse holdSeat( @Parameter(hidden = true) @@ -105,4 +67,4 @@ BaseResponse holdSeat( @Parameter(description = "좌석 ID", example = "101") @PathVariable Long seatId ); -} \ No newline at end of file +} diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatController.java b/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatController.java index 0afcbb611..fcb77ce53 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatController.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/controller/ScheduleSeatController.java @@ -27,7 +27,6 @@ public class ScheduleSeatController implements ScheduleSeatApi { private final ScheduleSeatService scheduleSeatService; private final ScheduleSeatStateService scheduleSeatStateService; - /** === 회차별 좌석 전체 / 상태별 조회 === */ @GetMapping("/{scheduleId}/seats") public BaseResponse> getScheduleSeats( @PathVariable Long scheduleId, @@ -39,7 +38,6 @@ public BaseResponse> getScheduleSeats( return BaseResponse.success(scheduleSeatService.getSeatsByStatus(scheduleId, status)); } - /** === 좌석 HOLD === */ @PostMapping("/{scheduleId}/seats/{seatId}/hold") public BaseResponse holdSeat( @CurrentUser UserPrincipal user, diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/dto/response/ScheduleSeatViewRes.java b/src/main/java/com/back/b2st/domain/scheduleseat/dto/response/ScheduleSeatViewRes.java index 3c48948d7..bd4065ff7 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/dto/response/ScheduleSeatViewRes.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/dto/response/ScheduleSeatViewRes.java @@ -7,6 +7,7 @@ public record ScheduleSeatViewRes( Long scheduleSeatId, Long seatId, + Long sectionId, String sectionName, String rowLabel, Integer seatNumber, diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepository.java b/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepository.java index cb3f43f3c..1b3dabb01 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepository.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepository.java @@ -12,11 +12,15 @@ import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.domain.seat.grade.entity.SeatGradeType; import jakarta.persistence.LockModeType; public interface ScheduleSeatRepository extends JpaRepository, ScheduleSeatRepositoryCustom { + /** 회차 좌석 생성 중복 방지 메서드 추가 */ + boolean existsByScheduleId(Long scheduleId); + /** scheduleId + seatId 로 특정 좌석 조회 */ Optional findByScheduleIdAndSeatId(Long scheduleId, Long seatId); @@ -51,4 +55,51 @@ Optional findByScheduleIdAndSeatIdWithLock( @Param("scheduleId") Long scheduleId, @Param("seatId") Long seatId ); + + /** 특정 회차의 특정 등급 AVAILABLE 좌석 조회 */ + @Query(""" + SELECT s + FROM ScheduleSeat s + JOIN SeatGrade sg ON s.seatId = sg.seatId + WHERE s.scheduleId = :scheduleId + AND s.status = 'AVAILABLE' + AND sg.grade = :grade + """) + List findAvailableSeatsByGrade( + @Param("scheduleId") Long scheduleId, + @Param("grade") SeatGradeType grade + ); + + /* === LOTTERY === */ + + /** scheduleId + scheduleSeatId(PK) 목록으로 좌석 조회 (검증용) */ + @Query(""" + select s + from ScheduleSeat s + where s.scheduleId = :scheduleId + and s.id in :scheduleSeatIds + """) + List findByScheduleIdAndScheduleSeatIdIn( + @Param("scheduleId") Long scheduleId, + @Param("scheduleSeatIds") List scheduleSeatIds + ); + + /** scheduleId + scheduleSeatId(PK) 목록을 SOLD로 일괄 업데이트 (추첨 확정용) */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update ScheduleSeat s + set s.status = :sold, + s.holdExpiredAt = null + where s.scheduleId = :scheduleId + and s.id in :scheduleSeatIds + and s.status = :available + """) + int updateStatusToSoldByScheduleSeatIds( + @Param("scheduleId") Long scheduleId, + @Param("scheduleSeatIds") List scheduleSeatIds, + @Param("available") SeatStatus available, + @Param("sold") SeatStatus sold + ); + + void deleteAllByScheduleIdIn(List scheduleIds); } diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepositoryImpl.java b/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepositoryImpl.java index d2f299066..f533b8af6 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepositoryImpl.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/repository/ScheduleSeatRepositoryImpl.java @@ -31,6 +31,7 @@ public List findSeats(Long scheduleId) { ScheduleSeatViewRes.class, scheduleSeat.id, seat.id, + seat.sectionId, seat.sectionName, seat.rowLabel, seat.seatNumber, @@ -75,6 +76,7 @@ public List findSeatsByStatus( ScheduleSeatViewRes.class, scheduleSeat.id, seat.id, + seat.sectionId, seat.sectionName, seat.rowLabel, seat.seatNumber, diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/service/AdminScheduleSeatService.java b/src/main/java/com/back/b2st/domain/scheduleseat/service/AdminScheduleSeatService.java new file mode 100644 index 000000000..fefcbf638 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/scheduleseat/service/AdminScheduleSeatService.java @@ -0,0 +1,34 @@ +package com.back.b2st.domain.scheduleseat.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.back.b2st.domain.scheduleseat.dto.response.ScheduleSeatViewRes; +import com.back.b2st.domain.scheduleseat.entity.SeatStatus; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminScheduleSeatService { + + private final ScheduleSeatService scheduleSeatService; + private final ScheduleSeatStateService seatStateService; + + @Transactional(readOnly = true) + public List getSeats(Long scheduleId) { + return scheduleSeatService.getSeats(scheduleId); + } + + @Transactional(readOnly = true) + public List getSeatsByStatus(Long scheduleId, SeatStatus status) { + return scheduleSeatService.getSeatsByStatus(scheduleId, status); + } + + @Transactional + public void releaseHold(Long scheduleId, Long seatId) { + seatStateService.releaseHold(scheduleId, seatId); + } +} diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatLockService.java b/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatLockService.java index 445b94438..c6bdea60f 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatLockService.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatLockService.java @@ -14,7 +14,7 @@ public class ScheduleSeatLockService { private final StringRedisTemplate redisTemplate; - private static final int LOCK_EXPIRE_SECONDS = 3; // 락 유지시간(3초 동안 좌석 HOLD → 그 뒤 자동 해제(TTL)) TODO: 더 짧게 하는 게 좋을까.. + private static final int LOCK_EXPIRE_SECONDS = 5; // 락 TTL: hold 처리 중 서버 다운/예외 시 자동 해제되게 하는 안전장치 /** === 좌석 lock 시도 (성공하면 true 반환) === */ public String tryLock(Long scheduleId, Long seatId, Long memberId) { diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatService.java b/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatService.java index db8a240a8..f977d501e 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatService.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatService.java @@ -1,5 +1,6 @@ package com.back.b2st.domain.scheduleseat.service; +import java.time.LocalDateTime; import java.util.List; import org.springframework.stereotype.Service; @@ -8,7 +9,9 @@ import com.back.b2st.domain.performanceschedule.error.PerformanceScheduleErrorCode; import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; import com.back.b2st.domain.scheduleseat.dto.response.ScheduleSeatViewRes; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; import com.back.b2st.domain.scheduleseat.entity.SeatStatus; +import com.back.b2st.domain.scheduleseat.error.ScheduleSeatErrorCode; import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; import com.back.b2st.global.error.exception.BusinessException; @@ -20,6 +23,35 @@ public class ScheduleSeatService { private final ScheduleSeatRepository scheduleSeatRepository; private final PerformanceScheduleRepository performanceScheduleRepository; + private final SeatHoldTokenService seatHoldTokenService; + + /** === 좌석 상태 유효한지 검사 === */ + @Transactional(readOnly = true) + public ScheduleSeat validateAndGetAttachableSeat( + Long scheduleId, + Long seatId, + Long memberId + ) { + // 1. HOLD 소유권 (Redis) + seatHoldTokenService.validateOwnership(scheduleId, seatId, memberId); + + // 2. ScheduleSeat 조회 + ScheduleSeat scheduleSeat = + scheduleSeatRepository.findByScheduleIdAndSeatId(scheduleId, seatId) + .orElseThrow(() -> new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_FOUND)); + + if (scheduleSeat.getStatus() != SeatStatus.HOLD) { + throw new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_HOLD); + } + + // 3. 만료 시간 조회 및 null 체크 + LocalDateTime expiredAt = scheduleSeat.getHoldExpiredAt(); + if (expiredAt == null || expiredAt.isBefore(LocalDateTime.now())) { + throw new BusinessException(ScheduleSeatErrorCode.SEAT_HOLD_EXPIRED); + } + + return scheduleSeat; + } /** === 특정 회차 전체 좌석 조회 === */ @Transactional(readOnly = true) diff --git a/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatStateService.java b/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatStateService.java index 5293ef764..af21b9017 100644 --- a/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatStateService.java +++ b/src/main/java/com/back/b2st/domain/scheduleseat/service/ScheduleSeatStateService.java @@ -5,6 +5,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.back.b2st.domain.performanceschedule.error.PerformanceScheduleErrorCode; +import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.queue.service.QueueAccessService; import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; import com.back.b2st.domain.scheduleseat.entity.SeatStatus; import com.back.b2st.domain.scheduleseat.error.ScheduleSeatErrorCode; @@ -19,13 +22,34 @@ public class ScheduleSeatStateService { private final ScheduleSeatLockService scheduleSeatLockService; private final SeatHoldTokenService seatHoldTokenService; + private final QueueAccessService queueAccessService; private final ScheduleSeatRepository scheduleSeatRepository; + private final PerformanceScheduleRepository performanceScheduleRepository; - /** === 상태 변경 AVAILABLE → HOLD === */ + /** === 좌석 잡기 (HOLD) === */ @Transactional public void holdSeat(Long memberId, Long scheduleId, Long seatId) { + // 0. 대기열 통과 검증 (락 이전) + Long performanceId = performanceScheduleRepository.findPerformanceIdByScheduleId(scheduleId) + .orElseThrow(() -> new BusinessException(PerformanceScheduleErrorCode.SCHEDULE_NOT_FOUND)); + + queueAccessService.assertEnterable(performanceId, memberId); + + holdSeatInternal(memberId, scheduleId, seatId); + } + + /** + * 신청예매(PRERESERVE) 좌석 HOLD는 대기열 없이 진행하므로, 대기열 검증을 생략한 HOLD를 제공합니다. + * - 신청예매 전용 컨트롤러에서만 사용해야 합니다. + */ + @Transactional + public void holdSeatWithoutQueue(Long memberId, Long scheduleId, Long seatId) { + holdSeatInternal(memberId, scheduleId, seatId); + } + + private void holdSeatInternal(Long memberId, Long scheduleId, Long seatId) { // 1. 좌석 락 획득 String lockValue = scheduleSeatLockService.tryLock(scheduleId, seatId, memberId); if (lockValue == null) { @@ -45,41 +69,6 @@ public void holdSeat(Long memberId, Long scheduleId, Long seatId) { } } - /** === Reservation 생성 직전에 DB 좌석이 유효한 HOLD 상태인지 검증 === */ - @Transactional(readOnly = true) - public void validateHoldState(Long scheduleId, Long seatId) { - ScheduleSeat seat = getScheduleSeat(scheduleId, seatId); - - if (seat.getStatus() != SeatStatus.HOLD) { - throw new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_HOLD); - } - - LocalDateTime expiredAt = seat.getHoldExpiredAt(); - LocalDateTime now = LocalDateTime.now(); - - if (expiredAt != null && expiredAt.isBefore(now)) { - throw new BusinessException(ScheduleSeatErrorCode.SEAT_HOLD_EXPIRED); - } - } - - /** === Reservation expiresAt 동기화를 위한 holdExpiredAt 반환 === */ - @Transactional(readOnly = true) - public LocalDateTime getHoldExpiredAtOrThrow(Long scheduleId, Long seatId) { - ScheduleSeat seat = getScheduleSeat(scheduleId, seatId); - - if (seat.getStatus() != SeatStatus.HOLD) { - throw new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_HOLD); - } - - LocalDateTime expiredAt = seat.getHoldExpiredAt(); - if (expiredAt == null) { - // hold()에서 반드시 세팅하지만, 데이터 이상 상황 방어 - throw new BusinessException(ScheduleSeatErrorCode.SEAT_HOLD_EXPIRED); - } - - return expiredAt; - } - /** === 만료된 HOLD 좌석을 AVAILABLE로 일괄 복구 === */ @Transactional public int releaseExpiredHoldsBatch() { @@ -98,10 +87,28 @@ public int releaseExpiredHoldsBatch() { return updated; } + @Transactional + public void releaseHold(Long scheduleId, Long seatId) { + changeToAvailable(scheduleId, seatId); + seatHoldTokenService.remove(scheduleId, seatId); + } + + @Transactional + public void releaseForceHold(Long scheduleId, Long seatId) { + forceToAvailable(scheduleId, seatId); + seatHoldTokenService.remove(scheduleId, seatId); + } + + @Transactional + public void confirmHold(Long scheduleId, Long seatId) { + changeToSold(scheduleId, seatId); + seatHoldTokenService.remove(scheduleId, seatId); + } + // === 상태 변경 AVAILABLE → HOLD === // @Transactional public void changeToHold(Long scheduleId, Long seatId) { - ScheduleSeat seat = getScheduleSeat(scheduleId, seatId); + ScheduleSeat seat = getScheduleSeatWithLock(scheduleId, seatId); if (seat.getStatus() == SeatStatus.SOLD) { throw new BusinessException(ScheduleSeatErrorCode.SEAT_ALREADY_SOLD); @@ -118,7 +125,7 @@ public void changeToHold(Long scheduleId, Long seatId) { // === 상태 변경 HOLD → AVAILABLE === // @Transactional public void changeToAvailable(Long scheduleId, Long seatId) { - ScheduleSeat seat = getScheduleSeat(scheduleId, seatId); + ScheduleSeat seat = getScheduleSeatWithLock(scheduleId, seatId); if (seat.getStatus() == SeatStatus.AVAILABLE) { return; @@ -131,10 +138,25 @@ public void changeToAvailable(Long scheduleId, Long seatId) { seat.release(); } + @Transactional + public void forceToAvailable(Long scheduleId, Long seatId) { + ScheduleSeat seat = getScheduleSeatWithLock(scheduleId, seatId); + + if (seat.getStatus() == SeatStatus.AVAILABLE) { + seatHoldTokenService.remove(scheduleId, seatId); + return; + } + + // SOLD든 HOLD든 운영 복구 목적으로 AVAILABLE로 강제 + seat.release(); + + seatHoldTokenService.remove(scheduleId, seatId); + } + // === 상태 변경 HOLD → SOLD === // @Transactional public void changeToSold(Long scheduleId, Long seatId) { - ScheduleSeat seat = getScheduleSeat(scheduleId, seatId); + ScheduleSeat seat = getScheduleSeatWithLock(scheduleId, seatId); if (seat.getStatus() == SeatStatus.SOLD) { return; @@ -147,10 +169,10 @@ public void changeToSold(Long scheduleId, Long seatId) { seat.sold(); } - // === 좌석 조회 공통 로직 === // - private ScheduleSeat getScheduleSeat(Long scheduleId, Long seatId) { + // === 좌석 조회 공통 로직 (락) === // + private ScheduleSeat getScheduleSeatWithLock(Long scheduleId, Long seatId) { return scheduleSeatRepository - .findByScheduleIdAndSeatId(scheduleId, seatId) + .findByScheduleIdAndSeatIdWithLock(scheduleId, seatId) .orElseThrow(() -> new BusinessException(ScheduleSeatErrorCode.SEAT_NOT_FOUND)); } } diff --git a/src/main/java/com/back/b2st/domain/seat/grade/repository/SeatGradeRepository.java b/src/main/java/com/back/b2st/domain/seat/grade/repository/SeatGradeRepository.java index eeb8b8f1c..5023a53e3 100644 --- a/src/main/java/com/back/b2st/domain/seat/grade/repository/SeatGradeRepository.java +++ b/src/main/java/com/back/b2st/domain/seat/grade/repository/SeatGradeRepository.java @@ -9,10 +9,17 @@ import com.back.b2st.domain.seat.grade.dto.SeatCountByGrade; import com.back.b2st.domain.seat.grade.entity.SeatGrade; +import com.back.b2st.domain.seat.grade.entity.SeatGradeType; public interface SeatGradeRepository extends JpaRepository { Optional findTopByPerformanceIdAndSeatIdOrderByIdDesc(Long performanceId, Long seatId); + Optional findTopByPerformanceIdAndGradeOrderByIdDesc(Long performanceId, SeatGradeType grade); + + boolean existsByPerformanceIdAndSeatId(Long performanceId, Long seatId); + + void deleteAllByPerformanceId(Long performanceId); + /** * 특정 공연의 등급별 좌석 수 */ diff --git a/src/main/java/com/back/b2st/domain/seat/seat/dto/response/SeatInfoRes.java b/src/main/java/com/back/b2st/domain/seat/seat/dto/response/SeatInfoRes.java index 1f341499d..031b894ab 100644 --- a/src/main/java/com/back/b2st/domain/seat/seat/dto/response/SeatInfoRes.java +++ b/src/main/java/com/back/b2st/domain/seat/seat/dto/response/SeatInfoRes.java @@ -7,6 +7,7 @@ public record SeatInfoRes( String sectionName, String rowLabel, Integer seatNumber, - SeatGradeType grade + SeatGradeType grade, + Integer price ) { } diff --git a/src/main/java/com/back/b2st/domain/seat/seat/repository/SeatRepository.java b/src/main/java/com/back/b2st/domain/seat/seat/repository/SeatRepository.java index 345997a5e..877172194 100644 --- a/src/main/java/com/back/b2st/domain/seat/seat/repository/SeatRepository.java +++ b/src/main/java/com/back/b2st/domain/seat/seat/repository/SeatRepository.java @@ -11,9 +11,12 @@ import com.back.b2st.domain.seat.seat.entity.Seat; public interface SeatRepository extends JpaRepository { + + List findByVenueId(Long venueId); + @Query(value = """ SELECT new com.back.b2st.domain.seat.seat.dto.response.SeatInfoRes( - s.id, s.sectionName, s.rowLabel, s.seatNumber, g.grade + s.id, s.sectionName, s.rowLabel, s.seatNumber, g.grade, g.price ) FROM Seat s JOIN SeatGrade g ON g.seatId = s.id @@ -26,7 +29,7 @@ boolean existsByVenueIdAndSectionIdAndSectionNameAndRowLabelAndSeatNumber( @Query(""" SELECT new com.back.b2st.domain.seat.seat.dto.response.SeatInfoRes( - s.id, s.sectionName, s.rowLabel, s.seatNumber, g.grade + s.id, s.sectionName, s.rowLabel, s.seatNumber, g.grade, g.price ) FROM Seat s JOIN SeatGrade g ON g.seatId = s.id @@ -36,7 +39,7 @@ boolean existsByVenueIdAndSectionIdAndSectionNameAndRowLabelAndSeatNumber( @Query(""" SELECT new com.back.b2st.domain.seat.seat.dto.response.SeatInfoRes( - s.id, s.sectionName, s.rowLabel, s.seatNumber, g.grade + s.id, s.sectionName, s.rowLabel, s.seatNumber, g.grade, g.price ) FROM Seat s JOIN SeatGrade g ON g.seatId = s.id diff --git a/src/main/java/com/back/b2st/domain/ticket/dto/response/TicketRes.java b/src/main/java/com/back/b2st/domain/ticket/dto/response/TicketRes.java index 731965518..ed0477dc0 100644 --- a/src/main/java/com/back/b2st/domain/ticket/dto/response/TicketRes.java +++ b/src/main/java/com/back/b2st/domain/ticket/dto/response/TicketRes.java @@ -1,5 +1,6 @@ package com.back.b2st.domain.ticket.dto.response; +import com.back.b2st.domain.ticket.entity.AcquisitionType; import com.back.b2st.domain.ticket.entity.TicketStatus; import lombok.Builder; @@ -17,4 +18,5 @@ public class TicketRes { private String rowLabel; private Integer seatNumber; private Long performanceId; + private AcquisitionType acquisitionType; // 티켓 획득 경로 } diff --git a/src/main/java/com/back/b2st/domain/ticket/entity/AcquisitionType.java b/src/main/java/com/back/b2st/domain/ticket/entity/AcquisitionType.java new file mode 100644 index 000000000..174f9bfc4 --- /dev/null +++ b/src/main/java/com/back/b2st/domain/ticket/entity/AcquisitionType.java @@ -0,0 +1,7 @@ +package com.back.b2st.domain.ticket.entity; + +public enum AcquisitionType { + RESERVATION, // 예매로 받은 티켓 + TRANSFER, // 양도로 받은 티켓 + EXCHANGE // 교환으로 받은 티켓 +} \ No newline at end of file diff --git a/src/main/java/com/back/b2st/domain/ticket/repository/TicketRepository.java b/src/main/java/com/back/b2st/domain/ticket/repository/TicketRepository.java index cea5c24ea..4eee935d4 100644 --- a/src/main/java/com/back/b2st/domain/ticket/repository/TicketRepository.java +++ b/src/main/java/com/back/b2st/domain/ticket/repository/TicketRepository.java @@ -4,11 +4,47 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.back.b2st.domain.ticket.entity.Ticket; public interface TicketRepository extends JpaRepository { + interface MissingTicketKey { + Long getReservationId(); + + Long getMemberId(); + + Long getSeatId(); + } + + List findAllByReservationIdAndMemberId(Long reservationId, Long memberId); + Optional findByReservationIdAndMemberIdAndSeatId(Long reservationId, Long memberId, Long seatId); + boolean existsByReservationIdAndMemberId(Long reservationId, Long memberId); + List findByMemberId(Long memberId); + + List findByReservationId(Long reservationId); + + @Query(""" + select + rs.reservationId as reservationId, + r.memberId as memberId, + ss.seatId as seatId + from ReservationSeat rs + join Reservation r on r.id = rs.reservationId + join ScheduleSeat ss on ss.id = rs.scheduleSeatId + left join Ticket t + on t.reservationId = rs.reservationId + and t.memberId = r.memberId + and t.seatId = ss.seatId + where r.memberId = :memberId + and r.status = com.back.b2st.domain.reservation.entity.ReservationStatus.COMPLETED + and t.id is null + """) + List findMissingTicketsForMember(@Param("memberId") Long memberId); + + void deleteAllByReservationIdIn(List reservationIds); } diff --git a/src/main/java/com/back/b2st/domain/ticket/service/TicketService.java b/src/main/java/com/back/b2st/domain/ticket/service/TicketService.java index 0f7fa9204..11c9a96f7 100644 --- a/src/main/java/com/back/b2st/domain/ticket/service/TicketService.java +++ b/src/main/java/com/back/b2st/domain/ticket/service/TicketService.java @@ -1,6 +1,11 @@ package com.back.b2st.domain.ticket.service; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; @@ -9,29 +14,42 @@ import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.booking.entity.PrereservationBooking; +import com.back.b2st.domain.prereservation.booking.repository.PrereservationBookingRepository; import com.back.b2st.domain.reservation.entity.Reservation; +import com.back.b2st.domain.reservation.dto.response.ReservationSeatInfo; import com.back.b2st.domain.reservation.repository.ReservationRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; +import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; +import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; import com.back.b2st.domain.seat.seat.entity.Seat; import com.back.b2st.domain.seat.seat.repository.SeatRepository; import com.back.b2st.domain.ticket.dto.response.TicketRes; +import com.back.b2st.domain.ticket.entity.AcquisitionType; import com.back.b2st.domain.ticket.entity.Ticket; import com.back.b2st.domain.ticket.entity.TicketStatus; import com.back.b2st.domain.ticket.error.TicketErrorCode; import com.back.b2st.domain.ticket.repository.TicketRepository; +import com.back.b2st.domain.trade.entity.Trade; +import com.back.b2st.domain.trade.entity.TradeType; +import com.back.b2st.domain.trade.entity.TradeStatus; +import com.back.b2st.domain.trade.repository.TradeRepository; import com.back.b2st.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor -@Slf4j public class TicketService { private final TicketRepository ticketRepository; private final SeatRepository seatRepository; private final ReservationRepository reservationRepository; private final PerformanceScheduleRepository performanceScheduleRepository; + private final ReservationSeatRepository reservationSeatRepository; + private final PrereservationBookingRepository prereservationBookingRepository; + private final ScheduleSeatRepository scheduleSeatRepository; + private final TradeRepository tradeRepository; public Ticket createTicket(Long reservationId, Long memberId, Long seatId) { return ticketRepository.findByReservationIdAndMemberIdAndSeatId(reservationId, memberId, seatId) @@ -117,29 +135,155 @@ public Ticket restoreTicket(Long ticketId) { return ticket; } + @Transactional + public void ensureTicketsForReservation(Long reservationId) { + Reservation reservation = reservationRepository.findById(reservationId).orElse(null); + if (reservation == null) { + return; + } + + List seats = reservationSeatRepository.findSeatInfos(reservationId); + if (seats.isEmpty()) { + return; + } + + for (ReservationSeatInfo seat : seats) { + createTicket(reservationId, reservation.getMemberId(), seat.seatId()); + } + } + + private void ensureTicketsForCompletedReservations(Long memberId) { + List missingTickets = ticketRepository.findMissingTicketsForMember(memberId); + for (TicketRepository.MissingTicketKey key : missingTickets) { + createTicket(key.getReservationId(), key.getMemberId(), key.getSeatId()); + } + } + + @Transactional public List getMyTickets(Long memberId) { + ensureTicketsForCompletedReservations(memberId); + List tickets = ticketRepository.findByMemberId(memberId); + // 구매자로 받은 완료된 거래 조회 (교환/양도) + List completedTrades = tradeRepository.findAllByBuyerIdAndStatus(memberId, TradeStatus.COMPLETED); + Map acquisitionTypeBySeatId = computeAcquisitionTypeBySeatId(completedTrades); + return tickets.stream() - .map(ticket -> { - Seat seat = seatRepository.findById(ticket.getSeatId()) - .orElseThrow(() -> new BusinessException(TicketErrorCode.TICKET_NOT_FOUND)); - Reservation reservation = reservationRepository.findById(ticket.getReservationId()) + .map(ticket -> toTicketResOrNull(ticket, acquisitionTypeBySeatId)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private TicketRes toTicketResOrNull(Ticket ticket, Map acquisitionTypeBySeatId) { + Seat seat = seatRepository.findById(ticket.getSeatId()).orElse(null); + if (seat == null) { + return null; + } + + PerformanceSchedule schedule; + try { + schedule = resolveScheduleForTicket(ticket); + } catch (BusinessException e) { + return null; + } + + AcquisitionType acquisitionType = + acquisitionTypeBySeatId.getOrDefault(ticket.getSeatId(), AcquisitionType.RESERVATION); + + return TicketRes.builder() + .ticketId(ticket.getId()) + .reservationId(ticket.getReservationId()) + .seatId(ticket.getSeatId()) + .status(ticket.getStatus()) + .sectionName(seat.getSectionName()) + .rowLabel(seat.getRowLabel()) + .seatNumber(seat.getSeatNumber()) + .performanceId(schedule.getPerformance().getPerformanceId()) + .acquisitionType(acquisitionType) + .build(); + } + + private Map computeAcquisitionTypeBySeatId(List completedTrades) { + if (completedTrades.isEmpty()) { + return Map.of(); + } + + Set tradeTicketIds = completedTrades.stream() + .map(Trade::getTicketId) + .collect(Collectors.toSet()); + + Map originalTicketsById = ticketRepository.findAllById(tradeTicketIds).stream() + .collect(Collectors.toMap(Ticket::getId, Function.identity(), (a, b) -> a)); + + Map acquisitionTypeBySeatId = new HashMap<>(); + for (Trade trade : completedTrades) { + Ticket originalTicket = originalTicketsById.get(trade.getTicketId()); + if (originalTicket == null) { + continue; + } + + acquisitionTypeBySeatId.putIfAbsent( + originalTicket.getSeatId(), + trade.getType() == TradeType.TRANSFER ? AcquisitionType.TRANSFER : AcquisitionType.EXCHANGE + ); + } + + return acquisitionTypeBySeatId; + } + + private PerformanceSchedule resolveScheduleForTicket(Ticket ticket) { + Reservation reservation = reservationRepository.findById(ticket.getReservationId()).orElse(null); + + if (reservation != null) { + List reservationSeats = reservationSeatRepository.findSeatInfos(reservation.getId()); + boolean isSeatPartOfReservation = reservationSeats.stream() + .anyMatch(seatInfo -> seatInfo.seatId().equals(ticket.getSeatId())); + + if (isSeatPartOfReservation) { + return performanceScheduleRepository.findById(reservation.getScheduleId()) .orElseThrow(() -> new BusinessException(TicketErrorCode.TICKET_NOT_FOUND)); - PerformanceSchedule schedule = performanceScheduleRepository.findById(reservation.getScheduleId()) + } + } + + PrereservationBooking booking = prereservationBookingRepository.findById(ticket.getReservationId()).orElse(null); + if (booking != null) { + ScheduleSeat scheduleSeat = scheduleSeatRepository.findById(booking.getScheduleSeatId()) + .orElseThrow(() -> new BusinessException(TicketErrorCode.TICKET_NOT_FOUND)); + + if (scheduleSeat.getSeatId().equals(ticket.getSeatId())) { + return performanceScheduleRepository.findById(booking.getScheduleId()) .orElseThrow(() -> new BusinessException(TicketErrorCode.TICKET_NOT_FOUND)); + } + } - return TicketRes.builder() - .ticketId(ticket.getId()) - .reservationId(ticket.getReservationId()) - .seatId(ticket.getSeatId()) - .status(ticket.getStatus()) - .sectionName(seat.getSectionName()) - .rowLabel(seat.getRowLabel()) - .seatNumber(seat.getSeatNumber()) - .performanceId(schedule.getPerformance().getPerformanceId()) - .build(); - }) - .collect(Collectors.toList()); + // 레거시/데이터 불일치 케이스: 기존 동작처럼 reservationId로 scheduleId를 역추적한다. + if (reservation != null) { + return performanceScheduleRepository.findById(reservation.getScheduleId()) + .orElseThrow(() -> new BusinessException(TicketErrorCode.TICKET_NOT_FOUND)); + } + + throw new BusinessException(TicketErrorCode.TICKET_NOT_FOUND); } + + @Transactional + public void cancelTicketsByReservation(Long reservationId, Long memberId) { + + List tickets = + ticketRepository.findAllByReservationIdAndMemberId(reservationId, memberId); + + if (tickets.isEmpty()) { + return; + } + + for (Ticket ticket : tickets) { + switch (ticket.getStatus()) { + case ISSUED -> ticket.cancel(); + case CANCELED -> { + } + default -> throw new BusinessException(TicketErrorCode.TICKET_NOT_CANCELABLE); + } + } + } + } diff --git a/src/main/java/com/back/b2st/domain/trade/dto/response/TradeRes.java b/src/main/java/com/back/b2st/domain/trade/dto/response/TradeRes.java index 4f44617eb..129921e97 100644 --- a/src/main/java/com/back/b2st/domain/trade/dto/response/TradeRes.java +++ b/src/main/java/com/back/b2st/domain/trade/dto/response/TradeRes.java @@ -10,6 +10,7 @@ public record TradeRes( Long tradeId, Long memberId, Long performanceId, + String performanceTitle, // 공연명 추가 Long scheduleId, Long ticketId, TradeType type, @@ -22,11 +23,12 @@ public record TradeRes( LocalDateTime createdAt ) { - public static TradeRes from(Trade trade) { + public static TradeRes from(Trade trade, String performanceTitle) { return new TradeRes( trade.getId(), trade.getMemberId(), trade.getPerformanceId(), + performanceTitle, // 공연명 포함 trade.getScheduleId(), trade.getTicketId(), trade.getType(), diff --git a/src/main/java/com/back/b2st/domain/trade/error/TradeErrorCode.java b/src/main/java/com/back/b2st/domain/trade/error/TradeErrorCode.java index ff640401d..56c5cb837 100644 --- a/src/main/java/com/back/b2st/domain/trade/error/TradeErrorCode.java +++ b/src/main/java/com/back/b2st/domain/trade/error/TradeErrorCode.java @@ -16,7 +16,8 @@ public enum TradeErrorCode implements ErrorCode { TRADE_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "X002", "신청을 찾을 수 없습니다."), UNAUTHORIZED_TRADE_ACCESS(HttpStatus.FORBIDDEN, "X003", "접근 권한이 없습니다."), UNAUTHORIZED_TRADE_REQUEST_ACCESS(HttpStatus.FORBIDDEN, "X004", "접근 권한이 없습니다."), - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "X005", "잘못된 요청입니다."); + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "X005", "잘못된 요청입니다."), + TRANSFER_PRICE_EXCEEDS_ORIGINAL(HttpStatus.BAD_REQUEST, "X006", "양도 가격은 정가 이하만 가능합니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/back/b2st/domain/trade/metrics/TradeMetrics.java b/src/main/java/com/back/b2st/domain/trade/metrics/TradeMetrics.java new file mode 100644 index 000000000..781f4593d --- /dev/null +++ b/src/main/java/com/back/b2st/domain/trade/metrics/TradeMetrics.java @@ -0,0 +1,77 @@ +package com.back.b2st.domain.trade.metrics; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.stereotype.Component; + +import com.back.b2st.domain.trade.entity.TradeType; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; + +@Component +public class TradeMetrics { + private final MeterRegistry registry; + private final AtomicInteger activeTradeCount = new AtomicInteger(0); + private final DistributionSummary tradePriceSummary; + + public TradeMetrics(MeterRegistry registry) { + this.registry = registry; + Gauge.builder("trade_active_count", activeTradeCount, AtomicInteger::get) + .description("현재 활성 거래 수") + .register(registry); + this.tradePriceSummary = DistributionSummary.builder("trade_price") + .description("양도 금액 분포") + .baseUnit("won") + .publishPercentiles(0.5, 0.95) + .register(registry); + } + + /** 거래 생성 기록 (양도/교환) */ + public void recordTradeCreated(TradeType type, Integer price) { + Counter.builder("trade_total") + .tag("action", "created") + .tag("type", type.name()) + .register(registry) + .increment(); + activeTradeCount.incrementAndGet(); + if (type == TradeType.TRANSFER && price != null) { + tradePriceSummary.record(price); + } + } + + /** 거래 완료 기록 */ + public void recordTradeCompleted(TradeType type) { + Counter.builder("trade_total") + .tag("action", "completed") + .tag("type", type.name()) + .register(registry) + .increment(); + activeTradeCount.decrementAndGet(); + } + + /** 거래 취소 기록 */ + public void recordTradeCancelled(TradeType type) { + Counter.builder("trade_total") + .tag("action", "cancelled") + .tag("type", type.name()) + .register(registry) + .increment(); + activeTradeCount.decrementAndGet(); + } + + /** 거래 요청 기록 */ + public void recordTradeRequest(TradeType type) { + Counter.builder("trade_request_total") + .tag("type", type.name()) + .register(registry) + .increment(); + } + + /** 활성 거래 수 설정 (배치용) */ + public void setActiveTradeCount(int count) { + activeTradeCount.set(count); + } +} diff --git a/src/main/java/com/back/b2st/domain/trade/repository/TradeRepository.java b/src/main/java/com/back/b2st/domain/trade/repository/TradeRepository.java index e0ee03897..5334d7a97 100644 --- a/src/main/java/com/back/b2st/domain/trade/repository/TradeRepository.java +++ b/src/main/java/com/back/b2st/domain/trade/repository/TradeRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.back.b2st.domain.trade.entity.Trade; import com.back.b2st.domain.trade.entity.TradeStatus; @@ -23,4 +25,16 @@ public interface TradeRepository extends JpaRepository { List findAllByBuyerIdAndTypeAndStatusOrderByPurchasedAtDesc(Long buyerId, TradeType type, TradeStatus status); List findAllByMemberIdAndTypeAndStatusOrderByPurchasedAtDesc(Long memberId, TradeType type, TradeStatus status); + + // 구매자가 받은 완료된 거래 조회 (티켓 획득 경로 확인용) + List findAllByBuyerIdAndStatus(Long buyerId, TradeStatus status); + + @Query(""" + select t.id + from Trade t + where t.performanceId = :performanceId + """) + List findIdsByPerformanceId(@Param("performanceId") Long performanceId); + + void deleteAllByPerformanceId(Long performanceId); } diff --git a/src/main/java/com/back/b2st/domain/trade/repository/TradeRequestRepository.java b/src/main/java/com/back/b2st/domain/trade/repository/TradeRequestRepository.java index 088fba369..ecc08aa71 100644 --- a/src/main/java/com/back/b2st/domain/trade/repository/TradeRequestRepository.java +++ b/src/main/java/com/back/b2st/domain/trade/repository/TradeRequestRepository.java @@ -17,4 +17,6 @@ public interface TradeRequestRepository extends JpaRepository findByTradeAndStatus(Trade trade, TradeRequestStatus status); List findByRequesterIdAndStatus(Long requesterId, TradeRequestStatus status); + + void deleteAllByTrade_IdIn(List tradeIds); } diff --git a/src/main/java/com/back/b2st/domain/trade/service/TradeService.java b/src/main/java/com/back/b2st/domain/trade/service/TradeService.java index 91ff529b0..fbb7c382c 100644 --- a/src/main/java/com/back/b2st/domain/trade/service/TradeService.java +++ b/src/main/java/com/back/b2st/domain/trade/service/TradeService.java @@ -8,15 +8,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.back.b2st.domain.performance.entity.Performance; +import com.back.b2st.domain.performance.repository.PerformanceRepository; import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; -import com.back.b2st.domain.reservation.entity.Reservation; -import com.back.b2st.domain.reservation.repository.ReservationRepository; -import com.back.b2st.domain.seat.seat.entity.Seat; -import com.back.b2st.domain.seat.seat.repository.SeatRepository; -import com.back.b2st.domain.ticket.entity.Ticket; -import com.back.b2st.domain.ticket.repository.TicketRepository; -import com.back.b2st.domain.trade.dto.request.CreateTradeReq; + import com.back.b2st.domain.reservation.entity.Reservation; + import com.back.b2st.domain.reservation.repository.ReservationRepository; + import com.back.b2st.domain.seat.grade.repository.SeatGradeRepository; + import com.back.b2st.domain.seat.seat.entity.Seat; + import com.back.b2st.domain.seat.seat.repository.SeatRepository; + import com.back.b2st.domain.ticket.entity.Ticket; + import com.back.b2st.domain.ticket.repository.TicketRepository; + import com.back.b2st.domain.trade.dto.request.CreateTradeReq; import com.back.b2st.domain.trade.dto.request.UpdateTradeReq; import com.back.b2st.domain.trade.dto.response.CreateTradeRes; import com.back.b2st.domain.trade.dto.response.TradeRes; @@ -36,20 +39,23 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class TradeService { + public class TradeService { - private final TradeRepository tradeRepository; - private final TradeRequestRepository tradeRequestRepository; - private final TicketRepository ticketRepository; - private final SeatRepository seatRepository; - private final ReservationRepository reservationRepository; - private final PerformanceScheduleRepository performanceScheduleRepository; + private final TradeRepository tradeRepository; + private final TradeRequestRepository tradeRequestRepository; + private final TicketRepository ticketRepository; + private final SeatRepository seatRepository; + private final SeatGradeRepository seatGradeRepository; + private final ReservationRepository reservationRepository; + private final PerformanceScheduleRepository performanceScheduleRepository; + private final PerformanceRepository performanceRepository; public TradeRes getTrade(Long tradeId) { Trade trade = tradeRepository.findById(tradeId) .orElseThrow(() -> new BusinessException(TradeErrorCode.TRADE_NOT_FOUND)); - return TradeRes.from(trade); + String performanceTitle = getPerformanceTitle(trade.getPerformanceId()); + return TradeRes.from(trade, performanceTitle); } public Page getTrades(TradeType type, TradeStatus status, Pageable pageable) { @@ -65,7 +71,10 @@ public Page getTrades(TradeType type, TradeStatus status, Pageable pag trades = tradeRepository.findAll(pageable); } - return trades.map(TradeRes::from); + return trades.map(trade -> { + String performanceTitle = getPerformanceTitle(trade.getPerformanceId()); + return TradeRes.from(trade, performanceTitle); + }); } @Transactional @@ -101,15 +110,17 @@ public List createTrade(CreateTradeReq request, Long memberId) { .orElseThrow(() -> new BusinessException(TradeErrorCode.INVALID_REQUEST, "보유하지 않은 티켓입니다.")); Long scheduleId = reservation.getScheduleId(); - PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) - .orElseThrow(() -> new BusinessException(TradeErrorCode.INVALID_REQUEST, "보유하지 않은 티켓입니다.")); - Long performanceId = schedule.getPerformance().getPerformanceId(); + PerformanceSchedule schedule = performanceScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(TradeErrorCode.INVALID_REQUEST, "보유하지 않은 티켓입니다.")); + Long performanceId = schedule.getPerformance().getPerformanceId(); + + validateTransferPriceWithinOriginalPrice(request, performanceId, ticket.getSeatId()); - Trade trade = TradeMapper.toEntity( - request, - ticket, - seat, - reservation, + Trade trade = TradeMapper.toEntity( + request, + ticket, + seat, + reservation, performanceId, scheduleId, memberId @@ -138,15 +149,19 @@ private void validateTicketNotDuplicated(Long ticketId) { } @Transactional - public void updateTrade(Long tradeId, UpdateTradeReq request, Long memberId) { - Trade trade = findTradeById(tradeId); + public void updateTrade(Long tradeId, UpdateTradeReq request, Long memberId) { + Trade trade = findTradeById(tradeId); - validateTradeOwner(trade, memberId); - validateTradeIsActive(trade); - validateTradeIsTransfer(trade); + validateTradeOwner(trade, memberId); + validateTradeIsActive(trade); + validateTradeIsTransfer(trade); - trade.updatePrice(request.price()); - } + Ticket ticket = ticketRepository.findById(trade.getTicketId()) + .orElseThrow(() -> new BusinessException(TradeErrorCode.INVALID_REQUEST, "보유하지 않은 티켓입니다.")); + validateTransferPriceWithinOriginalPrice(request.price(), trade.getPerformanceId(), ticket.getSeatId()); + + trade.updatePrice(request.price()); + } @Transactional public void deleteTrade(Long tradeId, Long memberId) { @@ -164,17 +179,37 @@ private Trade findTradeById(Long tradeId) { .orElseThrow(() -> new BusinessException(TradeErrorCode.TRADE_NOT_FOUND)); } - private void validateTradeType(CreateTradeReq request) { - if (request.type() == TradeType.EXCHANGE) { - if (request.price() != null) { - throw new BusinessException(TradeErrorCode.INVALID_REQUEST, "교환은 가격을 설정할 수 없습니다."); + private void validateTradeType(CreateTradeReq request) { + if (request.type() == TradeType.EXCHANGE) { + if (request.price() != null) { + throw new BusinessException(TradeErrorCode.INVALID_REQUEST, "교환은 가격을 설정할 수 없습니다."); + } + } else if (request.type() == TradeType.TRANSFER) { + if (request.price() == null || request.price() <= 0) { + throw new BusinessException(TradeErrorCode.INVALID_REQUEST, "양도 가격은 필수입니다."); + } } - } else if (request.type() == TradeType.TRANSFER) { - if (request.price() == null || request.price() <= 0) { - throw new BusinessException(TradeErrorCode.INVALID_REQUEST, "양도 가격은 필수입니다."); + } + + private void validateTransferPriceWithinOriginalPrice(CreateTradeReq request, Long performanceId, Long seatId) { + if (request.type() != TradeType.TRANSFER) { + return; + } + validateTransferPriceWithinOriginalPrice(request.price(), performanceId, seatId); + } + + private void validateTransferPriceWithinOriginalPrice(Integer price, Long performanceId, Long seatId) { + if (price == null) { + return; + } + Integer originalPrice = seatGradeRepository.findTopByPerformanceIdAndSeatIdOrderByIdDesc(performanceId, seatId) + .map(seatGrade -> seatGrade.getPrice()) + .orElseThrow(() -> new BusinessException(TradeErrorCode.INVALID_REQUEST, "좌석 가격 정보가 없습니다.")); + + if (price > originalPrice) { + throw new BusinessException(TradeErrorCode.TRANSFER_PRICE_EXCEEDS_ORIGINAL); } } - } private void validateTradeOwner(Trade trade, Long memberId) { if (!trade.getMemberId().equals(memberId)) { @@ -204,4 +239,10 @@ private void validateNoPendingRequests(Trade trade) { throw new BusinessException(TradeErrorCode.INVALID_REQUEST, "대기 중인 교환 신청이 있어 삭제할 수 없습니다."); } } + + private String getPerformanceTitle(Long performanceId) { + return performanceRepository.findById(performanceId) + .map(Performance::getTitle) + .orElse("알 수 없음"); + } } diff --git a/src/main/java/com/back/b2st/global/alert/AlertService.java b/src/main/java/com/back/b2st/global/alert/AlertService.java new file mode 100644 index 000000000..39bb41d71 --- /dev/null +++ b/src/main/java/com/back/b2st/global/alert/AlertService.java @@ -0,0 +1,9 @@ +package com.back.b2st.global.alert; + +import com.back.b2st.domain.auth.dto.response.SecurityThreatRes; + +public interface AlertService { + void sendSecurityAlert(SecurityThreatRes threat); + + void sendAccountLockedAlert(String email, String clientIp); +} diff --git a/src/main/java/com/back/b2st/global/alert/SlackAlertService.java b/src/main/java/com/back/b2st/global/alert/SlackAlertService.java new file mode 100644 index 000000000..5ee1f723a --- /dev/null +++ b/src/main/java/com/back/b2st/global/alert/SlackAlertService.java @@ -0,0 +1,121 @@ +package com.back.b2st.global.alert; + +import static com.back.b2st.global.util.MaskingUtil.*; + +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.back.b2st.domain.auth.dto.response.SecurityThreatRes; +import com.back.b2st.domain.auth.dto.response.SecurityThreatRes.SeverityLevel; + +import lombok.extern.slf4j.Slf4j; + +/** + * Slack Incoming Webhook 알림 서비스 + */ +@Service +@Slf4j +public class SlackAlertService implements AlertService { + + // 심각도별 이모지 매핑 + private static final Map SEVERITY_EMOJIS = Map.of(SeverityLevel.LOW, "🟢", + SeverityLevel.MEDIUM, "🟡", SeverityLevel.HIGH, "🟠", SeverityLevel.CRITICAL, "🔴"); + + private final RestClient restClient; + private final boolean enabled; + private final String webhookUrl; + + /** + * 생성자 - 설정 값 주입 + */ + public SlackAlertService(@Value("${alert.enabled:false}") boolean enabled, + @Value("${alert.slack.webhook-url:}") String webhookUrl) { + this.enabled = enabled; + this.webhookUrl = webhookUrl; + this.restClient = RestClient.builder().build(); + } + + /** + * 보안 위협 알림 전송 + */ + @Async + @Override + public void sendSecurityAlert(SecurityThreatRes threat) { + // 설정이 안되어 있으면 무시 + if (!isConfigured()) + return; + + String emoji = SEVERITY_EMOJIS.getOrDefault(threat.severity(), "🟢"); + // 페이로드 빌드 및 전송 + String payload = buildSecurityAlertPayload(threat, emoji); + sendToSlack(payload); + } + + /** + * 계정 잠금 알림 전송 + */ + @Async + @Override + public void sendAccountLockedAlert(String email, String clientIp) { + // 설정이 안되어 있으면 무시 + if (!isConfigured()) + return; + + String payload = """ + {"text": "🔒 계정 잠금 발생\\n• 이메일: %s\\n• IP: %s"} + """.formatted(maskEmail(email), clientIp); + sendToSlack(payload); + } + + // 설정 확인 + private boolean isConfigured() { + if (!enabled || webhookUrl.isBlank()) { + log.debug("Slack 알림 비활성화 또는 URL 미설정"); + return false; + } + return true; + } + + // 보안 위협 페이로드 빌드 + private String buildSecurityAlertPayload(SecurityThreatRes threat, String emoji) { + return """ + { + "blocks": [ + { + "type": "header", + "text": {"type": "plain_text", "text": "%s 보안 위협 감지", "emoji": true} + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*유형:*\\n%s"}, + {"type": "mrkdwn", "text": "*심각도:*\\n%s"}, + {"type": "mrkdwn", "text": "*IP:*\\n`%s`"}, + {"type": "mrkdwn", "text": "*횟수:*\\n%d"} + ] + } + ] + } + """.formatted(emoji, threat.threatType(), threat.severity(), threat.clientIp(), threat.count()); + } + + // Slack으로 페이로드 전송 + private void sendToSlack(String payload) { + try { + restClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .toBodilessEntity(); + log.info("Slack 알림 발송 완료"); + } catch (Exception e) { + log.error("Slack 알림 발송 실패: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/back/b2st/global/async/AsyncConfig.java b/src/main/java/com/back/b2st/global/async/AsyncConfig.java index c194a13bf..3bd4b429c 100644 --- a/src/main/java/com/back/b2st/global/async/AsyncConfig.java +++ b/src/main/java/com/back/b2st/global/async/AsyncConfig.java @@ -51,7 +51,7 @@ public Executor loginEventExecutor() { executor.setQueueCapacity(100); // 대기 큐 executor.setThreadNamePrefix("login-event-"); executor.setRejectedExecutionHandler((r, e) -> { - // 큐가 가득 차면 버리고 로그만 남김 (로그인 성능 영향 없도록) + // 큐 가득 차면 버리고 로그만 남김 (로그인 성능 영향 없도록) LoggerFactory.getLogger("LoginEventExecutor").warn("로그인 이벤트 처리 큐 가득 참. 이벤트 무시됨."); }); @@ -59,4 +59,25 @@ public Executor loginEventExecutor() { return executor; } + + /** + * 가입 이벤트 처리용 Executor 빈 등록 + * - 가입 로그 저장 등 비동기 처리 + * - 메인 가입 흐름에 영향 없도록 + */ + @Bean + public Executor signupEventExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(3); // 기본 스레드 수 + executor.setMaxPoolSize(5); // 최대 스레드 수 + executor.setQueueCapacity(100); // 대기 큐 + executor.setThreadNamePrefix("signup-event-"); + executor.setRejectedExecutionHandler((r, e) -> { + // 큐 가득 차면 버리고 로그만 남김 (가입 성능 영향 없도록) + LoggerFactory.getLogger("SignupEventExecutor").warn("가입 이벤트 처리 큐 가득 참. 이벤트 무시됨."); + }); + executor.initialize(); + return executor; + } } diff --git a/src/main/java/com/back/b2st/global/config/AlertConfig.java b/src/main/java/com/back/b2st/global/config/AlertConfig.java new file mode 100644 index 000000000..48630ef1e --- /dev/null +++ b/src/main/java/com/back/b2st/global/config/AlertConfig.java @@ -0,0 +1,12 @@ +package com.back.b2st.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "alert") +public record AlertConfig( + boolean enabled, + SlackConfig slack +) { + public record SlackConfig(String webhookUrl) { + } +} diff --git a/src/main/java/com/back/b2st/global/config/RedisConnectionConfig.java b/src/main/java/com/back/b2st/global/config/RedisConnectionConfig.java new file mode 100644 index 000000000..6f537ee2f --- /dev/null +++ b/src/main/java/com/back/b2st/global/config/RedisConnectionConfig.java @@ -0,0 +1,139 @@ +package com.back.b2st.global.config; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisClusterConfiguration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.util.StringUtils; + +import tools.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +/** + * Spring Data Redis Connection Factory 설정 + * + * Redisson과 별도로 Spring Data Redis (@EnableRedisRepositories)를 위한 + * RedisConnectionFactory를 설정합니다. + * + * Cluster 모드일 때 Spring Boot의 기본 자동 설정이 제대로 작동하지 않아 + * 수동으로 설정합니다. + */ +@Configuration +@Slf4j +public class RedisConnectionConfig { + + @Value("${spring.data.redis.mode:single}") + private String redisMode; + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + @Value("${spring.data.redis.cluster.nodes:}") + private String clusterNodes; + + @Bean + @Primary + public RedisConnectionFactory redisConnectionFactory() { + if ("cluster".equalsIgnoreCase(redisMode)) { + log.info("Redis Cluster 모드로 RedisConnectionFactory 구성"); + return createClusterConnectionFactory(); + } else { + log.info("Redis Single Server 모드로 RedisConnectionFactory 구성"); + return createStandaloneConnectionFactory(); + } + } + + private RedisConnectionFactory createClusterConnectionFactory() { + if (!StringUtils.hasText(clusterNodes)) { + throw new IllegalArgumentException( + "Cluster 모드 사용 시 'spring.data.redis.cluster.nodes' 설정이 필요합니다. " + + "예: localhost:7000,localhost:7001,localhost:7002" + ); + } + + // Cluster 노드 주소 리스트로 변환 + List nodeList = Arrays.stream(clusterNodes.split(",")) + .map(String::trim) + .toList(); + + RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration(nodeList); + + if (StringUtils.hasText(redisPassword)) { + clusterConfig.setPassword(redisPassword); + } + + LettuceConnectionFactory factory = new LettuceConnectionFactory(clusterConfig); + factory.afterPropertiesSet(); + + log.info("Redis Cluster ConnectionFactory 구성 완료 - Nodes: {}", nodeList); + return factory; + } + + private RedisConnectionFactory createStandaloneConnectionFactory() { + RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration(); + standaloneConfig.setHostName(redisHost); + standaloneConfig.setPort(redisPort); + + if (StringUtils.hasText(redisPassword)) { + standaloneConfig.setPassword(redisPassword); + } + + LettuceConnectionFactory factory = new LettuceConnectionFactory(standaloneConfig); + factory.afterPropertiesSet(); + + log.info("Redis Standalone ConnectionFactory 구성 완료 - Host: {}, Port: {}", redisHost, redisPort); + return factory; + } + + /** + * RedisTemplate 빈 생성 + * QueueRedisRepository에서 사용하는 RedisTemplate을 제공합니다. + * + * Spring Data Redis 4.0+ 호환: GenericJacksonJsonRedisSerializer 사용 + * (GenericJackson2JsonRedisSerializer의 대체재) + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key는 String 직렬화 + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // Value는 JSON 직렬화 (Object 타입 지원) + // Spring Data Redis 4.0+ 호환: GenericJacksonJsonRedisSerializer 사용 + // Jackson 3의 ObjectMapper를 생성하여 전달합니다. + // Jackson 3에서는 activateDefaultTyping이 제거되었으므로 기본 설정을 사용합니다. + ObjectMapper objectMapper = new ObjectMapper(); + GenericJacksonJsonRedisSerializer jsonSerializer = new GenericJacksonJsonRedisSerializer(objectMapper); + + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + // 기본 직렬화 설정 + template.setDefaultSerializer(jsonSerializer); + + template.afterPropertiesSet(); + log.info("RedisTemplate 빈 생성 완료 (Spring Data Redis 4.0+ 호환)"); + return template; + } +} + diff --git a/src/main/java/com/back/b2st/global/config/RedisScriptConfig.java b/src/main/java/com/back/b2st/global/config/RedisScriptConfig.java index 2453f7f8a..f796b2274 100644 --- a/src/main/java/com/back/b2st/global/config/RedisScriptConfig.java +++ b/src/main/java/com/back/b2st/global/config/RedisScriptConfig.java @@ -8,110 +8,63 @@ /** * Redis Lua Script 설정 * - * 대규모 트래픽 환경에서 원자성과 성능을 보장하기 위한 Lua Script 빈 등록 - * - * ⚠️ 개발 초기 단계 - 대기열 기능 활성화 시에만 로드 - * application.yml에서 `queue.enabled: true` 설정 필요 + * ✅ WAITING: ZSET(score=timestampMillis) + * ✅ ENTERABLE: ZSET(score=expiresAtSeconds) (SoT) */ @Configuration @ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) public class RedisScriptConfig { /** - * WAITING → ENTERABLE 이동 스크립트 (원자적 실행) + * WAITING → ENTERABLE 이동 스크립트 (원자적 승격 + 상한 게이트) * - * 3개의 Redis 명령을 하나의 원자적 작업으로 실행: - * 1. WAITING ZSET에서 제거 - * 2. 개별 User Key 생성 + TTL 설정 - * 3. ENTERABLE SET에 추가 + * KEYS[1]: waitingKey (ZSET) + * KEYS[2]: enterableKey (ZSET, score=expiresAtSeconds) * - * 장점: - * - 네트워크 왕복 3번 → 1번으로 감소 (성능 3배 향상) - * - 원자성 보장 (중간 실패 시 롤백) - * - 대규모 트래픽에서 안정성 확보 + * ARGV[1]: userId + * ARGV[2]: expiresAtSeconds + * ARGV[3]: nowSeconds + * ARGV[4]: maxActiveUsers * - * @return RedisScript + * Return: + * 1: MOVED + * 0: SKIPPED (WAITING에 없거나 이미 유효 ENTERABLE) + * 2: REJECTED_FULL */ @Bean public RedisScript moveToEnterableScript() { String script = """ - -- KEYS[1]: waiting key (ZSET) - -- KEYS[2]: user key (STRING + TTL) - -- KEYS[3]: set key (SET) - -- ARGV[1]: userId (String) - -- ARGV[2]: ttl (seconds, Integer) - - -- 1. WAITING ZSET에서 제거 - redis.call('ZREM', KEYS[1], ARGV[1]) - - -- 2. 개별 User Key 생성 + TTL 설정 - redis.call('SETEX', KEYS[2], ARGV[2], '1') - - -- 3. ENTERABLE SET에 추가 - redis.call('SADD', KEYS[3], ARGV[1]) - - return 1 - """; + local userId = ARGV[1] + local expiresAt = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local maxActive = tonumber(ARGV[4]) - return RedisScript.of(script, Long.class); - } + -- 0) 이미 ENTERABLE이고 유효하면 idempotent skip + local current = redis.call('ZSCORE', KEYS[2], userId) + if current and tonumber(current) >= now then + return 0 + end - /** - * 대기열에 사용자 추가 + 카운트 증가 스크립트 (원자적 실행) - * - * 2개의 Redis 명령을 하나의 원자적 작업으로 실행: - * 1. WAITING ZSET에 추가 - * 2. 전체 카운트 증가 - * - * @return RedisScript - */ - @Bean - public RedisScript addToWaitingWithCountScript() { - String script = """ - -- KEYS[1]: waiting key (ZSET) - -- KEYS[2]: count key (STRING) - -- ARGV[1]: userId (String) - -- ARGV[2]: timestamp (score) - - -- 1. WAITING ZSET에 추가 - redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]) - - -- 2. 카운트 증가 - local count = redis.call('INCR', KEYS[2]) - - return count - """; + -- 1) WAITING에 있는지 확인 + 기존 score 확보(순번 보존) + local waitingScore = redis.call('ZSCORE', KEYS[1], userId) + if not waitingScore then + return 0 + end - return RedisScript.of(script, Long.class); - } + -- 2) ENTERABLE 유효 인원 게이트 + local activeCount = redis.call('ZCOUNT', KEYS[2], now, '+inf') + if activeCount >= maxActive then + -- WAITING 유지(순번 유지) - ZREM 하지 않음 + return 2 + end + + -- 3) 이동 수행 + redis.call('ZREM', KEYS[1], userId) + redis.call('ZADD', KEYS[2], expiresAt, userId) - /** - * ENTERABLE에서 제거 + 카운트 감소 스크립트 (원자적 실행) - * - * 3개의 Redis 명령을 하나의 원자적 작업으로 실행: - * 1. 개별 User Key 삭제 - * 2. ENTERABLE SET에서 제거 - * 3. 카운트 감소 - * - * @return RedisScript - */ - @Bean - public RedisScript removeFromEnterableScript() { - String script = """ - -- KEYS[1]: user key (STRING) - -- KEYS[2]: set key (SET) - -- ARGV[1]: userId (String) - - -- 1. 개별 User Key 삭제 - redis.call('DEL', KEYS[1]) - - -- 2. ENTERABLE SET에서 제거 - redis.call('SREM', KEYS[2], ARGV[1]) - return 1 """; return RedisScript.of(script, Long.class); } } - diff --git a/src/main/java/com/back/b2st/global/config/RedissonConfig.java b/src/main/java/com/back/b2st/global/config/RedissonConfig.java index f230d32bd..64291ba81 100644 --- a/src/main/java/com/back/b2st/global/config/RedissonConfig.java +++ b/src/main/java/com/back/b2st/global/config/RedissonConfig.java @@ -1,5 +1,7 @@ package com.back.b2st.global.config; +import java.util.Arrays; +import lombok.extern.slf4j.Slf4j; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; @@ -7,40 +9,214 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; /** * Redisson 설정 * * 분산 락(Distributed Lock)을 위한 Redisson Client 설정 + * + * 지원 모드: + * - single: 단일 Redis 서버 + * - sentinel: Redis Sentinel + * - cluster: Redis Cluster */ @Configuration @ConditionalOnProperty(name = "queue.enabled", havingValue = "true", matchIfMissing = false) +@Slf4j public class RedissonConfig { - @Value("${spring.data.redis.host}") + // 단일 서버 모드 설정 + @Value("${spring.data.redis.host:localhost}") private String redisHost; - @Value("${spring.data.redis.port}") + @Value("${spring.data.redis.port:6379}") private int redisPort; @Value("${spring.data.redis.password:}") private String redisPassword; + // Redis 모드 설정 (single, sentinel, cluster) + @Value("${spring.data.redis.mode:single}") + private String redisMode; + + // Sentinel 모드 설정 + @Value("${spring.data.redis.sentinel.master:mymaster}") + private String sentinelMaster; + + @Value("${spring.data.redis.sentinel.nodes:}") + private String sentinelNodes; // "sentinel1:26379,sentinel2:26379,sentinel3:26379" + + // Cluster 모드 설정 + @Value("${spring.data.redis.cluster.nodes:}") + private String clusterNodes; // "node1:6379,node2:6379,node3:6379" + + // 대규모 트래픽 최적화 설정 + @Value("${spring.data.redis.cluster.master-connection-pool-size:64}") + private int clusterMasterConnectionPoolSize; + + @Value("${spring.data.redis.cluster.slave-connection-pool-size:64}") + private int clusterSlaveConnectionPoolSize; + + @Value("${spring.data.redis.cluster.master-connection-minimum-idle-size:10}") + private int clusterMasterConnectionMinimumIdleSize; + + @Value("${spring.data.redis.cluster.slave-connection-minimum-idle-size:10}") + private int clusterSlaveConnectionMinimumIdleSize; + + @Value("${spring.data.redis.cluster.scan-interval:2000}") + private int clusterScanInterval; + + @Value("${spring.data.redis.cluster.timeout:5000}") + private int clusterTimeout; + + @Value("${spring.data.redis.cluster.connect-timeout:10000}") + private int clusterConnectTimeout; + @Bean public RedissonClient redissonClient() { Config config = new Config(); + // Redisson 4.0: Config 레벨에서 password, TCP 설정 + if (StringUtils.hasText(redisPassword)) { + config.setPassword(redisPassword); + } + config.setTcpKeepAlive(true); + config.setTcpNoDelay(true); + + switch (redisMode.toLowerCase()) { + case "sentinel" -> { + log.info("Redis Sentinel 모드로 Redisson 클라이언트 구성"); + configureSentinel(config); + } + case "cluster" -> { + log.info("Redis Cluster 모드로 Redisson 클라이언트 구성"); + configureCluster(config); + } + default -> { + log.info("Redis Single Server 모드로 Redisson 클라이언트 구성"); + configureSingleServer(config); + } + } + + return Redisson.create(config); + } + + /** + * Sentinel 모드 설정 + */ + private void configureSentinel(Config config) { + if (!StringUtils.hasText(sentinelNodes)) { + throw new IllegalArgumentException( + "Sentinel 모드 사용 시 'spring.data.redis.sentinel.nodes' 설정이 필요합니다. " + + "예: sentinel1:26379,sentinel2:26379,sentinel3:26379" + ); + } + + // Sentinel 주소 배열로 변환 + String[] sentinelAddresses = Arrays.stream(sentinelNodes.split(",")) + .map(node -> { + String trimmed = node.trim(); + return trimmed.startsWith("redis://") ? trimmed : "redis://" + trimmed; + }) + .toArray(String[]::new); + + config.useSentinelServers() + .setMasterName(sentinelMaster) + .addSentinelAddress(sentinelAddresses) + .setDatabase(0) + // Master 연결 풀 설정 + .setMasterConnectionPoolSize(10) + .setMasterConnectionMinimumIdleSize(2) + // Slave 연결 풀 설정 (읽기 부하 분산) + .setSlaveConnectionPoolSize(10) + .setSlaveConnectionMinimumIdleSize(2) + // 재시도 설정 + .setRetryAttempts(3) + .setRetryInterval(1500) + // 타임아웃 설정 + .setTimeout(3000) + .setConnectTimeout(10000); + + log.info("Sentinel 구성 완료 - Master: {}, Nodes: {}", sentinelMaster, Arrays.toString(sentinelAddresses)); + } + + /** + * Cluster 모드 설정 (대규모 트래픽 최적화) + * + * 수평 확장 지원 (샤딩) + * 최소 6개 노드 권장 (3 Master + 3 Slave) + * + * 대규모 트래픽 최적화: + * - Connection Pool 크기 증가 (64개) + * - 타임아웃 최적화 + * - 읽기 부하 분산 (Slave에서 읽기) + */ + private void configureCluster(Config config) { + if (!StringUtils.hasText(clusterNodes)) { + throw new IllegalArgumentException( + "Cluster 모드 사용 시 'spring.data.redis.cluster.nodes' 설정이 필요합니다. " + + "예: node1:6379,node2:6379,node3:6379" + ); + } + + // Cluster 노드 주소 배열로 변환 + String[] nodeAddresses = Arrays.stream(clusterNodes.split(",")) + .map(node -> { + String trimmed = node.trim(); + return trimmed.startsWith("redis://") ? trimmed : "redis://" + trimmed; + }) + .toArray(String[]::new); + + config.useClusterServers() + .addNodeAddress(nodeAddresses) + // 클러스터 노드 스캔 간격 (밀리초) - 대규모 트래픽에서는 더 자주 스캔 + .setScanInterval(clusterScanInterval) + // Master 연결 풀 설정 (대규모 트래픽: 64개) + .setMasterConnectionPoolSize(clusterMasterConnectionPoolSize) + .setMasterConnectionMinimumIdleSize(clusterMasterConnectionMinimumIdleSize) + // Slave 연결 풀 설정 (읽기 부하 분산) + .setSlaveConnectionPoolSize(clusterSlaveConnectionPoolSize) + .setSlaveConnectionMinimumIdleSize(clusterSlaveConnectionMinimumIdleSize) + // 읽기 모드: Master에서만 읽기 (일관성) 또는 Slave에서 읽기 (성능) + // 일반 읽기는 SLAVE에서 읽어도 되지만, 락은 MASTER 필수 + // 현재는 SLAVE로 설정하되, 락은 별도로 MASTER 연결 사용 (Redisson이 자동 처리) + .setReadMode(org.redisson.config.ReadMode.SLAVE) // 일반 읽기: Slave에서 읽기로 부하 분산 + // 재시도 설정 + .setRetryAttempts(3) + .setRetryInterval(1500) + // 타임아웃 설정 (대규모 트래픽: 더 긴 타임아웃) + .setTimeout(clusterTimeout) + .setConnectTimeout(clusterConnectTimeout) + // 대규모 트래픽 최적화 + .setIdleConnectionTimeout(10000) + .setPingConnectionInterval(30000) + // Redisson 4.0+에서 모든 슬롯이 커버되지 않아도 연결 시도 + // (클러스터 초기화 중이거나 cluster-announce-ip 설정이 전파되는 동안 발생할 수 있음) + .setCheckSlotsCoverage(false); + + log.info("Cluster 구성 완료 (대규모 트래픽 최적화) - Nodes: {}, " + + "Master Pool: {}, Slave Pool: {}, Timeout: {}ms", + Arrays.toString(nodeAddresses), + clusterMasterConnectionPoolSize, + clusterSlaveConnectionPoolSize, + clusterTimeout); + } + + /** + * 단일 서버 모드 설정 + */ + private void configureSingleServer(Config config) { String address = String.format("redis://%s:%d", redisHost, redisPort); config.useSingleServer() .setAddress(address) - .setPassword(redisPassword.isEmpty() ? null : redisPassword) .setConnectionPoolSize(10) .setConnectionMinimumIdleSize(2) .setRetryAttempts(3) .setRetryInterval(1500); - return Redisson.create(config); + log.info("Single Server 구성 완료 - Address: {}", address); } } diff --git a/src/main/java/com/back/b2st/global/config/S3ConfigProperties.java b/src/main/java/com/back/b2st/global/config/S3ConfigProperties.java new file mode 100644 index 000000000..8d8a8a846 --- /dev/null +++ b/src/main/java/com/back/b2st/global/config/S3ConfigProperties.java @@ -0,0 +1,60 @@ +package com.back.b2st.global.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +/** + * AWS S3 설정 Properties (Presigned URL 기반 업로드/다운로드) + * + * - S3는 Private 유지 + * - 업로드/조회 모두 Presigned URL로 처리 + */ +@Validated +@ConfigurationProperties(prefix = "aws.s3") +public record S3ConfigProperties( + + /** S3 버킷 이름 (필수) */ + @NotBlank(message = "aws.s3.bucket은(는) 필수입니다.") + String bucket, + + /** + * AWS 리전 + * - 기본: ap-northeast-2 + * - 가능하면 설정으로 명시하는 것을 권장 + */ + String region, + + /** 업로드(PUT) Presigned URL 만료 시간 (초) */ + @Min(value = 1, message = "aws.s3.put-presign-expiration-seconds는 1 이상이어야 합니다.") + int putPresignExpirationSeconds, + + /** 조회(GET) Presigned URL 만료 시간 (초) */ + @Min(value = 1, message = "aws.s3.get-presign-expiration-seconds는 1 이상이어야 합니다.") + int getPresignExpirationSeconds, + + /** 업로드 파일 크기 제한 (바이트) */ + @Min(value = 1, message = "aws.s3.max-file-size는 1 이상이어야 합니다.") + long maxFileSize, + + /** 허용된 Content-Type 목록 */ + @NotEmpty(message = "aws.s3.allowed-content-types는 비어있을 수 없습니다.") + List allowedContentTypes + +) { + public S3ConfigProperties { + if (region == null || region.isBlank()) region = "ap-northeast-2"; + if (putPresignExpirationSeconds <= 0) putPresignExpirationSeconds = 300; + if (getPresignExpirationSeconds <= 0) getPresignExpirationSeconds = 300; + if (maxFileSize <= 0) maxFileSize = 10 * 1024 * 1024; + + if (allowedContentTypes == null || allowedContentTypes.isEmpty()) { + allowedContentTypes = List.of("image/jpeg", "image/png", "image/webp"); + } + } +} diff --git a/src/main/java/com/back/b2st/global/config/S3PresignerConfig.java b/src/main/java/com/back/b2st/global/config/S3PresignerConfig.java new file mode 100644 index 000000000..062718cb5 --- /dev/null +++ b/src/main/java/com/back/b2st/global/config/S3PresignerConfig.java @@ -0,0 +1,31 @@ +package com.back.b2st.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/** + * S3 Presigner Bean 설정 + * + * S3Presigner를 Bean으로 관리하여 재사용 + */ +@Configuration +public class S3PresignerConfig { + + private final S3ConfigProperties s3Config; + + public S3PresignerConfig(S3ConfigProperties s3Config) { + this.s3Config = s3Config; + } + + @Bean(destroyMethod = "close") + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(s3Config.region())) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} + diff --git a/src/main/java/com/back/b2st/global/init/DataInitializer.java b/src/main/java/com/back/b2st/global/init/DataInitializer.java index f83c18e72..49c79bba2 100644 --- a/src/main/java/com/back/b2st/global/init/DataInitializer.java +++ b/src/main/java/com/back/b2st/global/init/DataInitializer.java @@ -2,9 +2,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.IntStream; @@ -17,22 +17,32 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import com.back.b2st.domain.lottery.draw.service.DrawService; import com.back.b2st.domain.lottery.entry.entity.LotteryEntry; import com.back.b2st.domain.lottery.entry.repository.LotteryEntryRepository; +import com.back.b2st.domain.lottery.result.repository.LotteryResultRepository; import com.back.b2st.domain.member.entity.Member; import com.back.b2st.domain.member.repository.MemberRepository; import com.back.b2st.domain.payment.entity.DomainType; import com.back.b2st.domain.payment.entity.Payment; import com.back.b2st.domain.payment.entity.PaymentMethod; import com.back.b2st.domain.payment.repository.PaymentRepository; +import com.back.b2st.domain.payment.service.PaymentOneClickService; import com.back.b2st.domain.performance.entity.Performance; import com.back.b2st.domain.performance.entity.PerformanceStatus; import com.back.b2st.domain.performance.repository.PerformanceRepository; import com.back.b2st.domain.performanceschedule.entity.BookingType; import com.back.b2st.domain.performanceschedule.entity.PerformanceSchedule; import com.back.b2st.domain.performanceschedule.repository.PerformanceScheduleRepository; +import com.back.b2st.domain.prereservation.booking.repository.PrereservationBookingRepository; +import com.back.b2st.domain.prereservation.entry.entity.Prereservation; +import com.back.b2st.domain.prereservation.entry.repository.PrereservationRepository; +import com.back.b2st.domain.prereservation.policy.entity.PrereservationTimeTable; +import com.back.b2st.domain.prereservation.policy.repository.PrereservationTimeTableRepository; import com.back.b2st.domain.reservation.entity.Reservation; +import com.back.b2st.domain.reservation.entity.ReservationSeat; import com.back.b2st.domain.reservation.repository.ReservationRepository; +import com.back.b2st.domain.reservation.repository.ReservationSeatRepository; import com.back.b2st.domain.scheduleseat.entity.ScheduleSeat; import com.back.b2st.domain.scheduleseat.repository.ScheduleSeatRepository; import com.back.b2st.domain.seat.grade.entity.SeatGrade; @@ -54,10 +64,13 @@ @Slf4j @Component @RequiredArgsConstructor -@Profile("!test") +@Profile("!test & !prod") @Transactional public class DataInitializer implements CommandLineRunner { + private static final String TEST_PERFORMANCE_TITLE = "2024 아이유 콘서트 - HEREH WORLD TOUR"; + private static final String TEST_PRERESERVE_PLAY_TITLE = "연극 - B2ST 신청예매 테스트"; + private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; private final ScheduleSeatRepository scheduleSeatRepository; @@ -68,16 +81,29 @@ public class DataInitializer implements CommandLineRunner { private final PerformanceScheduleRepository performanceScheduleRepository; private final SeatGradeRepository seatGradeRepository; private final ReservationRepository reservationRepository; + private final ReservationSeatRepository reservationSeatRepository; private final PaymentRepository paymentRepository; private final TicketRepository ticketRepository; private final LotteryEntryRepository lotteryEntryRepository; + private final PrereservationTimeTableRepository prereservationTimeTableRepository; + private final PrereservationRepository prereservationRepository; + private final PrereservationBookingRepository prereservationBookingRepository; + private final DrawService drawService; + private final LotteryResultRepository lotteryResultRepository; + private final PaymentOneClickService paymentOneClickService; + + // Trade 관련 Repository 추가 + private final com.back.b2st.domain.trade.repository.TradeRepository tradeRepository; @Override public void run(String... args) throws Exception { // 서버 재시작시 중복 생성 방지 차 initMemberData(); initConnectedSet(); + initTradeData(); lottery(); + lotteryForDrawExecution(); + lotteryForSeatAllocation(); } private void initMemberData() { @@ -154,9 +180,15 @@ private void setAuthenticationContext(Member admin) { // 데이터 생성 1 private void initConnectedSet() { + // 신청예매 테스트 공연은 init 정책이 바뀐 경우에만 1회성으로 재생성 + recreatePrereservePerformance(); + // 중복 생성 방지: 이미 공연장이 있으면 스킵 if (venueRepository.count() > 0) { log.info("[DataInit] 이미 데이터 존재하여 초기화 스킵"); + seedPrereservationTimeTablesIfMissing(); + seedPrereservationApplicationsIfMissing(); + ensurePrereservationHoldTestAlwaysOpen(); return; } @@ -177,30 +209,75 @@ private void initConnectedSet() { .venue(venue) .title("2024 아이유 콘서트 - HEREH WORLD TOUR") .category("콘서트") - .posterUrl("") + .posterKey(null) .description(null) .startDate(LocalDateTime.of(2024, 12, 20, 19, 0)) .endDate(LocalDateTime.of(2024, 12, 29, 21, 0)) - .status(PerformanceStatus.ON_SALE) + .status(PerformanceStatus.ACTIVE) .build()); Long venueId = venue.getVenueId(); - // 회차 23개 추가 생성 + // 기존 회차 24개 생성 (일반예매/추첨 - 신청예매 구현 전 원래 데이터) List schedules = IntStream.rangeClosed(0, 23) .mapToObj(i -> PerformanceSchedule.builder() .performance(performance) .startAt(LocalDateTime.of(2025, 1, 1, 19, 0).plusDays(i)) .roundNo(i + 1) - .bookingType(i % 2 == 0 ? BookingType.SEAT : BookingType.LOTTERY) - .bookingOpenAt(LocalDateTime.of(2024, 12, 20, 12, 0)) - .bookingCloseAt(LocalDateTime.of(2024, 12, 25, 23, 59)) + .bookingType(i % 2 == 0 ? BookingType.FIRST_COME : BookingType.LOTTERY) + .bookingOpenAt(LocalDateTime.now().minusHours(1)) + .bookingCloseAt(LocalDateTime.now().plusDays(30)) .build() ).toList(); performanceScheduleRepository.saveAll(schedules); performanceSchedule = schedules.getFirst(); performanceSchedule2 = schedules.get(1); + // 신청예매(PRERESERVE) 테스트 시간 세팅 + // - 사전 신청: now < bookingOpenAt 이어야 신청 가능 + // - 실제 예매(HOLD/booking): now >= bookingOpenAt 이어야 진행 가능 + // 같은 회차에서 둘을 동시에 테스트할 수 없어서, 회차별로 bookingOpenAt을 다르게 세팅한다. + LocalDateTime nowHour = LocalDateTime.now() + .withMinute(0) + .withSecond(0) + .withNano(0); + LocalDateTime bookingOpenAtForHold = nowHour; // 오픈됨 → HOLD/예매 테스트용 + LocalDateTime bookingOpenAtForApply = nowHour.plusHours(24); // 오픈 전 → 사전 신청 테스트용 + LocalDateTime prereserveStartAtBase = LocalDateTime.now() + .withHour(19) + .withMinute(0) + .withSecond(0) + .withNano(0); + + // 신청예매 테스트 전용 공연(연극) 추가: 기존(1~24회차) 데이터와 겹치지 않게 별도 공연으로 분리 + // 구성은 기존 콘서트 데이터와 동일하게 맞추되(venue/필수 필드), 내용만 다르게 한다. + Performance prereservePlay = performanceRepository.save(Performance.builder() + .venue(venue) + .title(TEST_PRERESERVE_PLAY_TITLE) + .category("연극") + .posterKey(null) + .description("신청예매(사전신청) 기능 테스트용 연극 공연입니다.") + .startDate(prereserveStartAtBase) + .endDate(prereserveStartAtBase.plusDays(5)) + .status(PerformanceStatus.ACTIVE) + .build()); + + // 신청예매 테스트용 회차 추가 (1~6회차) + // - 예: 오늘이 1/5이면 1/5~1/10까지 선택 가능하도록 구성 + List prereserveSchedules = IntStream.rangeClosed(0, 5) + .mapToObj(idx -> PerformanceSchedule.builder() + .performance(prereservePlay) + .startAt(prereserveStartAtBase.plusDays(idx)) + .roundNo(1 + idx) + .bookingType(BookingType.PRERESERVE) + // - 1~2회차: 예매 오픈(=HOLD 가능) + // - 3~6회차: 사전 신청 가능(예매 오픈 전) + .bookingOpenAt(idx < 2 ? bookingOpenAtForHold : bookingOpenAtForApply) + .bookingCloseAt(LocalDateTime.now().plusDays(30)) + .build() + ).toList(); + performanceScheduleRepository.saveAll(prereserveSchedules); + // 구역 생성 sectionA = sectionRepository.save(Section.builder().venueId(venueId).sectionName("A").build()); sectionB = sectionRepository.save(Section.builder().venueId(venueId).sectionName("B").build()); @@ -214,6 +291,34 @@ record SectionConfig(Section section) { // 구역 설정 리스트 List
sections = List.of(sectionA, sectionB, sectionC); + // 신청 예매(BookingType.PRERESERVE) 시간표 시드 생성 + List timeTables = prereserveSchedules.stream() + .flatMap(schedule -> IntStream.range(0, sections.size()) + .mapToObj(idx -> { + LocalDateTime bookingOpenAt = schedule.getBookingOpenAt(); + LocalDateTime startAt = bookingOpenAt; + LocalDateTime endAt = schedule.getBookingCloseAt() != null + ? schedule.getBookingCloseAt() + : bookingOpenAt.plusDays(30); + + return PrereservationTimeTable.builder() + .performanceScheduleId(schedule.getPerformanceScheduleId()) + .sectionId(sections.get(idx).getId()) + .bookingStartAt(startAt) + .bookingEndAt(endAt) + .build(); + })) + .toList(); + prereservationTimeTableRepository.saveAll(timeTables); + log.info("[DataInit/Test] Prereservation time tables initialized. count={}", timeTables.size()); + + // 신청예매 테스트용 사전 신청 시드(user1/user2) + // - 예매 오픈된 회차(HOLD 테스트용)에 한해서만 미리 신청을 만들어 둔다. + // - 사전 신청 테스트용 회차는 프론트에서 직접 신청 → 신청 성공/실패 케이스를 확인할 수 있도록 비워둔다. + seedPrereservationApplications(prereserveSchedules, sections); + seedPrereservationApplicationsIfMissing(); + ensurePrereservationHoldTestAlwaysOpen(); + // 모든 구역의 좌석 생성 List seats = sections.stream() .flatMap(section -> IntStream.rangeClosed(1, 3) @@ -264,6 +369,38 @@ record SectionConfig(Section section) { .toList(); scheduleSeatRepository.saveAll(allScheduleSeats); + // 신청예매 회차에도 좌석을 붙여서(선점/예매 테스트 가능) 별도 생성 + List prereserveScheduleSeats = prereserveSchedules.stream() + .flatMap(schedule -> savedSeats.stream() + .map(seat -> ScheduleSeat.builder() + .scheduleId(schedule.getPerformanceScheduleId()) + .seatId(seat.getId()) + .build())) + .toList(); + scheduleSeatRepository.saveAll(prereserveScheduleSeats); + + // 신청예매 공연에도 좌석 등급(정가)을 별도 생성 (SeatGrade는 performanceId 기준) + List prereserveSeatGrades = IntStream.range(0, savedSeats.size()) + .mapToObj(idx -> { + int seatInSection = idx % 15; + int gradeGroup = seatInSection / 5; + return SeatGrade.builder() + .performanceId(prereservePlay.getPerformanceId()) + .seatId(savedSeats.get(idx).getId()) + .grade(switch (gradeGroup) { + case 0 -> SeatGradeType.VIP; + case 1 -> SeatGradeType.ROYAL; + default -> SeatGradeType.STANDARD; + }) + .price(switch (gradeGroup) { + case 0 -> 30000; + case 1 -> 20000; + default -> 10000; + }) + .build(); + }).toList(); + seatGradeRepository.saveAll(prereserveSeatGrades); + /** * A구역 (0~14): * - 좌석 0~4: VIP (30,000원) @@ -303,13 +440,21 @@ record SectionConfig(Section section) { Reservation reservation = Reservation.builder() .scheduleId(performanceSchedule.getPerformanceScheduleId()) .memberId(user1.getId()) - .seatId(reservedSeat.getId()) .expiresAt(LocalDateTime.now().plusMinutes(5)) .build(); - reservation.complete(LocalDateTime.now()); Reservation savedReservation = reservationRepository.save(reservation); + // Reservation ↔ ScheduleSeat 연결 + reservationSeatRepository.save( + ReservationSeat.builder() + .reservationId(savedReservation.getId()) + .scheduleSeatId(reservedScheduleSeat.getId()) + .build() + ); + + reservation.complete(LocalDateTime.now()); + // 결제 생성 (DONE 상태) Payment payment = Payment.builder() .orderId("ORDER-INIT-" + System.currentTimeMillis() + "-" + i) @@ -361,12 +506,19 @@ record SectionConfig(Section section) { Reservation paidReservation = Reservation.builder() .scheduleId(performanceSchedule.getPerformanceScheduleId()) .memberId(user1.getId()) - .seatId(paidSeat.getId()) .expiresAt(LocalDateTime.now().plusMinutes(5)) .build(); Reservation savedPaidReservation = reservationRepository.save(paidReservation); + // Reservation ↔ ScheduleSeat 연결 + reservationSeatRepository.save( + ReservationSeat.builder() + .reservationId(savedPaidReservation.getId()) + .scheduleSeatId(paidScheduleSeat.getId()) + .build() + ); + // codeisneverodd@gmail.com에 2개의 티켓 생성 Member user3 = memberRepository.findByEmail("codeisneverodd@gmail.com") .orElseThrow(() -> new IllegalStateException("user3 not found")); @@ -389,13 +541,20 @@ record SectionConfig(Section section) { Reservation reservation = Reservation.builder() .scheduleId(performanceSchedule.getPerformanceScheduleId()) .memberId(user3.getId()) - .seatId(reservedSeat.getId()) .expiresAt(LocalDateTime.now().plusMinutes(5)) .build(); - reservation.complete(LocalDateTime.now()); Reservation savedReservation = reservationRepository.save(reservation); + reservationSeatRepository.save( + ReservationSeat.builder() + .reservationId(savedReservation.getId()) + .scheduleSeatId(reservedScheduleSeat.getId()) + .build() + ); + + reservation.complete(LocalDateTime.now()); + // 결제 생성 (DONE 상태) Payment payment = Payment.builder() .orderId("ORDER-INIT-" + System.currentTimeMillis() + "-" + i) @@ -429,21 +588,260 @@ record SectionConfig(Section section) { Thread.currentThread().interrupt(); } } + + // user2 티켓은 initTradeData()에서 필요시 자동 생성됨 + } + + private void seedPrereservationApplications(List prereserveSchedules, List
sections) { + var user1 = memberRepository.findByEmail("user1@tt.com").orElse(null); + var user2 = memberRepository.findByEmail("user2@tt.com").orElse(null); + var user3 = memberRepository.findByEmail("codeisneverodd@gmail.com").orElse(null); + if (user1 == null && user2 == null && user3 == null) { + return; + } + + var members = java.util.List.of(user1, user2, user3).stream().filter(java.util.Objects::nonNull).toList(); + int created = 0; + + LocalDateTime now = LocalDateTime.now(); + for (PerformanceSchedule schedule : prereserveSchedules) { + // 예매 오픈 전(사전 신청 테스트용) 회차는 미리 신청을 생성하지 않는다. + if (now.isBefore(schedule.getBookingOpenAt())) { + continue; + } + + Long scheduleId = schedule.getPerformanceScheduleId(); + for (var member : members) { + for (Section section : sections) { + if (prereservationRepository.existsByPerformanceScheduleIdAndMemberIdAndSectionId( + scheduleId, member.getId(), section.getId() + )) { + continue; + } + prereservationRepository.save( + Prereservation.builder() + .performanceScheduleId(scheduleId) + .memberId(member.getId()) + .sectionId(section.getId()) + .build() + ); + created++; + } + } + } + + if (created > 0) { + log.info("[DataInit/Test] Prereservation applications seeded. created={}", created); + } + } + + /** + * 기존 DB에 user1/user2 신청 내역만 남아있는 경우가 있어, user3 포함해서 누락분을 보충한다. + * - 신청예매 테스트 공연에 한함(TEST_PRERESERVE_PLAY_TITLE) + * - 오픈된 회차(now >= bookingOpenAt)만 대상 + */ + private void seedPrereservationApplicationsIfMissing() { + var user1 = memberRepository.findByEmail("user1@tt.com").orElse(null); + var user2 = memberRepository.findByEmail("user2@tt.com").orElse(null); + var user3 = memberRepository.findByEmail("codeisneverodd@gmail.com").orElse(null); + if (user1 == null && user2 == null && user3 == null) { + return; + } + + var members = java.util.List.of(user1, user2, user3).stream().filter(java.util.Objects::nonNull).toList(); + LocalDateTime now = LocalDateTime.now(); + + List openSchedules = performanceScheduleRepository.findAll().stream() + .filter(schedule -> schedule.getBookingType() == BookingType.PRERESERVE) + .filter(schedule -> schedule.getBookingOpenAt() != null) + .filter(schedule -> schedule.getPerformance() != null) + .filter(schedule -> TEST_PRERESERVE_PLAY_TITLE.equals(schedule.getPerformance().getTitle())) + .filter(schedule -> !now.isBefore(schedule.getBookingOpenAt())) + .toList(); + + if (openSchedules.isEmpty()) { + return; + } + + int created = 0; + for (PerformanceSchedule schedule : openSchedules) { + Long scheduleId = schedule.getPerformanceScheduleId(); + Long venueId = schedule.getPerformance().getVenue().getVenueId(); + List
sections = sectionRepository.findByVenueId(venueId); + if (sections.isEmpty()) { + continue; + } + + for (var member : members) { + for (Section section : sections) { + if (prereservationRepository.existsByPerformanceScheduleIdAndMemberIdAndSectionId( + scheduleId, member.getId(), section.getId() + )) { + continue; + } + prereservationRepository.save( + Prereservation.builder() + .performanceScheduleId(scheduleId) + .memberId(member.getId()) + .sectionId(section.getId()) + .build() + ); + created++; + } + } + } + + if (created > 0) { + log.info("[DataInit/Test] Prereservation applications ensured. created={}", created); + } + } + + private void seedPrereservationTimeTablesIfMissing() { + List prereserveSchedules = performanceScheduleRepository.findAll().stream() + .filter(schedule -> schedule.getBookingType() == BookingType.PRERESERVE) + .filter(schedule -> schedule.getBookingOpenAt() != null) + .filter(schedule -> schedule.getPerformance() != null) + .filter(schedule -> schedule.getPerformance().getTitle() != null) + .filter(schedule -> TEST_PERFORMANCE_TITLE.equals(schedule.getPerformance().getTitle()) + || TEST_PRERESERVE_PLAY_TITLE.equals(schedule.getPerformance().getTitle())) + .toList(); + + if (prereserveSchedules.isEmpty()) { + return; + } + + int createdCount = 0; + for (PerformanceSchedule schedule : prereserveSchedules) { + Long scheduleId = schedule.getPerformanceScheduleId(); + var existing = prereservationTimeTableRepository + .findAllByPerformanceScheduleIdOrderByBookingStartAtAscSectionIdAsc(scheduleId); + var existingSectionIds = existing.stream() + .map(PrereservationTimeTable::getSectionId) + .collect(java.util.stream.Collectors.toSet()); + + Long venueId = schedule.getPerformance().getVenue().getVenueId(); + List
sections = sectionRepository.findByVenueId(venueId).stream() + .sorted(java.util.Comparator.comparingLong(Section::getId)) + .toList(); + if (sections.isEmpty()) { + continue; + } + + LocalDateTime bookingOpenAt = schedule.getBookingOpenAt(); + LocalDateTime bookingCloseAt = schedule.getBookingCloseAt(); + + List toCreate = new java.util.ArrayList<>(); + for (int idx = 0; idx < sections.size(); idx++) { + Section section = sections.get(idx); + if (existingSectionIds.contains(section.getId())) { + continue; + } + + LocalDateTime startAt = bookingOpenAt; + LocalDateTime endAt = bookingCloseAt != null ? bookingCloseAt : bookingOpenAt.plusDays(30); + if (!endAt.isAfter(startAt)) { + continue; + } + + toCreate.add(PrereservationTimeTable.builder() + .performanceScheduleId(scheduleId) + .sectionId(section.getId()) + .bookingStartAt(startAt) + .bookingEndAt(endAt) + .build()); + } + + if (!toCreate.isEmpty()) { + prereservationTimeTableRepository.saveAll(toCreate); + createdCount += toCreate.size(); + } + } + + if (createdCount > 0) { + log.info("[DataInit/Test] Prereservation time tables ensured. created={}", createdCount); + } + } + + /** + * 신청예매 테스트가 시간대(구역별 1시간 슬롯)에 묶이지 않도록, + * 신청예매 테스트 공연의 구역별 시간표를 bookingOpenAt ~ bookingCloseAt(없으면 +30일)로 확장한다. + */ + private void ensurePrereservationHoldTestAlwaysOpen() { + List prereserveSchedules = performanceScheduleRepository.findAll().stream() + .filter(schedule -> schedule.getBookingType() == BookingType.PRERESERVE) + .filter(schedule -> schedule.getBookingOpenAt() != null) + .filter(schedule -> schedule.getPerformance() != null) + .filter(schedule -> TEST_PRERESERVE_PLAY_TITLE.equals(schedule.getPerformance().getTitle())) + .toList(); + + if (prereserveSchedules.isEmpty()) { + return; + } + + int updated = 0; + for (PerformanceSchedule schedule : prereserveSchedules) { + Long venueId = schedule.getPerformance().getVenue().getVenueId(); + List
sections = sectionRepository.findByVenueId(venueId).stream() + .sorted(java.util.Comparator.comparingLong(Section::getId)) + .toList(); + if (sections.isEmpty()) { + continue; + } + + LocalDateTime startAtForSchedule = schedule.getBookingOpenAt(); + LocalDateTime endAtForSchedule = schedule.getBookingCloseAt() != null + ? schedule.getBookingCloseAt() + : startAtForSchedule.plusDays(30); + + Long scheduleId = schedule.getPerformanceScheduleId(); + List existing = prereservationTimeTableRepository + .findAllByPerformanceScheduleIdOrderByBookingStartAtAscSectionIdAsc(scheduleId); + Map bySectionId = existing.stream() + .collect(java.util.stream.Collectors.toMap( + PrereservationTimeTable::getSectionId, + tt -> tt, + (left, right) -> right + )); + + for (Section section : sections) { + var timeTable = bySectionId.get(section.getId()); + if (timeTable == null) { + prereservationTimeTableRepository.save(PrereservationTimeTable.builder() + .performanceScheduleId(scheduleId) + .sectionId(section.getId()) + .bookingStartAt(startAtForSchedule) + .bookingEndAt(endAtForSchedule) + .build()); + updated++; + continue; + } + + // 1시간 슬롯 등 과거 데이터가 남아있어도 "전체 기간 오픈"으로 정규화 + if (!startAtForSchedule.equals(timeTable.getBookingStartAt()) + || !endAtForSchedule.equals(timeTable.getBookingEndAt())) { + timeTable.updateBookingTime(startAtForSchedule, endAtForSchedule); + updated++; + } + } + } + + if (updated > 0) { + log.info("[DataInit/Test] Prereservation time tables widened. updated={}", updated); + } } // 추첨 데이터 private void lottery() { - - List members1 = createMembers(10, memberRepository, passwordEncoder); - List members2 = createMembers(10, memberRepository, passwordEncoder); - List members3 = createMembers(10, memberRepository, passwordEncoder); + List members1 = createMembers(3, memberRepository, passwordEncoder); + List members2 = createMembers(3, memberRepository, passwordEncoder); + List members3 = createMembers(3, memberRepository, passwordEncoder); Venue venue = createVenue("추첨공연장", venueRepository); List
sections = createSections(venue.getVenueId(), sectionRepository, "A", "B", "C"); List seats = createSeats(venue.getVenueId(), sections, 3, 5, seatRepository); Performance performance = createPerformance(venue, performanceRepository); - List schedules = createSchedules(performance, 3, BookingType.LOTTERY, + List schedules = createSchedules(performance, 2, BookingType.LOTTERY, performanceScheduleRepository); createSeatGrades(performance, seats, seatGradeRepository); @@ -454,10 +852,6 @@ private void lottery() { createLotteryEntry(members1, performance, schedules.get(1), SeatGradeType.STANDARD, lotteryEntryRepository); createLotteryEntry(members2, performance, schedules.get(1), SeatGradeType.VIP, lotteryEntryRepository); createLotteryEntry(members3, performance, schedules.get(1), SeatGradeType.ROYAL, lotteryEntryRepository); - - createLotteryEntry(members1, performance, schedules.get(2), SeatGradeType.STANDARD, lotteryEntryRepository); - createLotteryEntry(members2, performance, schedules.get(2), SeatGradeType.VIP, lotteryEntryRepository); - createLotteryEntry(members3, performance, schedules.get(2), SeatGradeType.ROYAL, lotteryEntryRepository); } /** @@ -472,7 +866,7 @@ public static List createMembers( int row = (int)(memberRepository.count() + 1); return IntStream.rangeClosed(row, row + count - 1) .mapToObj(i -> Member.builder() - .email("user" + i + "@test.com") + .email("user" + i + "@tt.com") .password(passwordEncoder.encode("1234567a!")) .name("테스트유저" + i) .role(Member.Role.MEMBER) @@ -513,10 +907,10 @@ public static Performance createPerformance( .venue(venue) .title("테스트 공연") .category("콘서트") - .posterUrl("") + .posterKey(null) .startDate(LocalDateTime.now()) .endDate(LocalDateTime.now().plusDays(7)) - .status(PerformanceStatus.ON_SALE) + .status(PerformanceStatus.ACTIVE) .build() ); } @@ -538,8 +932,8 @@ public static List createSchedules( .roundNo(i) .startAt(LocalDateTime.now().plusDays(i)) .bookingType(bookingType) - .bookingOpenAt(LocalDateTime.of(LocalDate.now().minusDays(1), LocalTime.MIDNIGHT)) - .bookingCloseAt(LocalDateTime.of(LocalDate.now().minusDays(1), LocalTime.of(12, 0))) + .bookingOpenAt(LocalDateTime.now().minusHours(1)) + .bookingCloseAt(LocalDateTime.now().plusDays(30)) .build() ) .toList() @@ -609,22 +1003,21 @@ public static void createSeatGrades( List grades = IntStream.range(0, seats.size()) .mapToObj(i -> { int group = (i % 15) / 5; + SeatGradeType grade = SeatGradeType.values()[ + ThreadLocalRandom.current().nextInt(SeatGradeType.values().length)]; + return SeatGrade.builder() .performanceId(performance.getPerformanceId()) .seatId(seats.get(i).getId()) - .grade(SeatGradeType.values()[ - ThreadLocalRandom.current().nextInt(SeatGradeType.values().length) - ]) - .price(switch (group) { - case 0 -> 30000; - case 1 -> 20000; + .grade(grade) + .price(switch (grade) { + case VIP -> 30000; + case ROYAL -> 20000; default -> 10000; }) .build(); }) - . - - toList(); + .toList(); repo.saveAll(grades); } @@ -653,4 +1046,563 @@ public static List createLotteryEntry( return repo.saveAll(lotteryEntries); } + /** + * 신청예매 테스트 공연 재생성(필요 시) + * - 기존 데이터가 현재 init 정책과 불일치하면 관련 데이터 정리 후 재생성 + * - 이미 정책에 맞게 생성되어 있으면 유지(매 실행마다 삭제하지 않음) + */ + private void recreatePrereservePerformance() { + List candidates = performanceRepository.findAll().stream() + .filter(p -> TEST_PRERESERVE_PLAY_TITLE.equals(p.getTitle())) + .toList(); + + Performance keep = candidates.stream() + .filter(p -> isPrereservePerformanceCompatible(p.getPerformanceId())) + .findFirst() + .orElse(null); + + if (keep != null) { + for (Performance p : candidates) { + if (p.getPerformanceId().equals(keep.getPerformanceId())) { + continue; + } + log.info("[DataInit] 중복 신청예매 공연 삭제: ID={}", p.getPerformanceId()); + deletePrereserveRelatedData(p.getPerformanceId()); + performanceRepository.delete(p); + } + return; + } + + for (Performance oldPerformance : candidates) { + log.info("[DataInit] 기존 신청예매 공연 삭제(정책 불일치): ID={}", oldPerformance.getPerformanceId()); + deletePrereserveRelatedData(oldPerformance.getPerformanceId()); + performanceRepository.delete(oldPerformance); + } + + // 공연장이 없으면 신청예매 공연도 생성 불가 + if (venueRepository.count() == 0) { + return; + } + + Venue venue = venueRepository.findAll().stream().findFirst().orElse(null); + if (venue == null) { + return; + } + + List
sections = sectionRepository.findByVenueId(venue.getVenueId()); + if (sections.isEmpty()) { + return; + } + + // 신청예매(PRERESERVE) 테스트 시간 세팅 + // - 사전 신청: now < bookingOpenAt 이어야 신청 가능 + // - 실제 예매(HOLD/booking): now >= bookingOpenAt 이어야 진행 가능 + LocalDateTime nowHour = LocalDateTime.now() + .withMinute(0) + .withSecond(0) + .withNano(0); + LocalDateTime bookingOpenAtForHold = nowHour; + LocalDateTime bookingOpenAtForApply = nowHour.plusHours(24); + LocalDateTime prereserveStartAtBase = LocalDateTime.now() + .withHour(19) + .withMinute(0) + .withSecond(0) + .withNano(0); + + Performance prereservePlay = performanceRepository.save(Performance.builder() + .venue(venue) + .title(TEST_PRERESERVE_PLAY_TITLE) + .category("연극") + .posterKey(null) + .description("신청예매(사전신청) 기능 테스트용 연극 공연입니다.") + .startDate(prereserveStartAtBase) + .endDate(prereserveStartAtBase.plusDays(5)) + .status(PerformanceStatus.ACTIVE) + .build()); + + log.info("[DataInit] 신청예매 공연 재생성: 날짜={} ~ {}", prereserveStartAtBase.toLocalDate(), + prereserveStartAtBase.plusDays(5).toLocalDate()); + + // 회차 생성 + List prereserveSchedules = IntStream.rangeClosed(0, 5) + .mapToObj(idx -> PerformanceSchedule.builder() + .performance(prereservePlay) + .startAt(prereserveStartAtBase.plusDays(idx)) + .roundNo(1 + idx) + .bookingType(BookingType.PRERESERVE) + .bookingOpenAt(idx < 2 ? bookingOpenAtForHold : bookingOpenAtForApply) + .bookingCloseAt(LocalDateTime.now().plusDays(30)) + .build() + ).toList(); + performanceScheduleRepository.saveAll(prereserveSchedules); + + log.info("[DataInit] 신청예매 회차 생성: {}개 (1~6회차)", prereserveSchedules.size()); + + // 시간표 생성 + List timeTables = prereserveSchedules.stream() + .flatMap(schedule -> IntStream.range(0, sections.size()) + .mapToObj(idx -> { + LocalDateTime bookingOpenAt = schedule.getBookingOpenAt(); + LocalDateTime startAt = bookingOpenAt; + LocalDateTime endAt = schedule.getBookingCloseAt() != null + ? schedule.getBookingCloseAt() + : bookingOpenAt.plusDays(30); + + return PrereservationTimeTable.builder() + .performanceScheduleId(schedule.getPerformanceScheduleId()) + .sectionId(sections.get(idx).getId()) + .bookingStartAt(startAt) + .bookingEndAt(endAt) + .build(); + })) + .toList(); + prereservationTimeTableRepository.saveAll(timeTables); + + log.info("[DataInit] 신청예매 시간표 생성: {}개", timeTables.size()); + + // 좌석 등급 생성 + List seats = seatRepository.findAll(); + if (!seats.isEmpty()) { + List prereserveSeatGrades = IntStream.range(0, seats.size()) + .mapToObj(idx -> { + int seatInSection = idx % 15; + int gradeGroup = seatInSection / 5; + return SeatGrade.builder() + .performanceId(prereservePlay.getPerformanceId()) + .seatId(seats.get(idx).getId()) + .grade(switch (gradeGroup) { + case 0 -> SeatGradeType.VIP; + case 1 -> SeatGradeType.ROYAL; + default -> SeatGradeType.STANDARD; + }) + .price(switch (gradeGroup) { + case 0 -> 30000; + case 1 -> 20000; + default -> 10000; + }) + .build(); + }).toList(); + seatGradeRepository.saveAll(prereserveSeatGrades); + + // 회차별 좌석 생성 + List prereserveScheduleSeats = prereserveSchedules.stream() + .flatMap(schedule -> seats.stream() + .map(seat -> ScheduleSeat.builder() + .scheduleId(schedule.getPerformanceScheduleId()) + .seatId(seat.getId()) + .build())) + .toList(); + scheduleSeatRepository.saveAll(prereserveScheduleSeats); + + log.info("[DataInit] 신청예매 좌석 등급 및 회차별 좌석 생성 완료"); + } + + // 사전 신청 데이터 시드 + seedPrereservationApplications(prereserveSchedules, sections); + } + + private void deletePrereserveRelatedData(Long performanceId) { + try { + List scheduleIds = performanceScheduleRepository + .findAllByPerformance_PerformanceIdOrderByStartAtAsc(performanceId) + .stream() + .map(PerformanceSchedule::getPerformanceScheduleId) + .toList(); + + if (!scheduleIds.isEmpty()) { + prereservationBookingRepository.deleteAllByScheduleIdIn(scheduleIds); + scheduleSeatRepository.deleteAllByScheduleIdIn(scheduleIds); + prereservationTimeTableRepository.deleteAllByPerformanceScheduleIdIn(scheduleIds); + prereservationRepository.deleteAllByPerformanceScheduleIdIn(scheduleIds); + performanceScheduleRepository.deleteAllByPerformanceId(performanceId); + } + + seatGradeRepository.deleteAllByPerformanceId(performanceId); + } catch (Exception e) { + log.warn("[DataInit] 신청예매 관련 데이터 정리 실패 - performanceId={}", performanceId, e); + } + } + + private boolean isPrereservePerformanceCompatible(Long performanceId) { + try { + List schedules = + performanceScheduleRepository.findAllByPerformance_PerformanceIdOrderByStartAtAsc(performanceId); + + List prereserveSchedules = schedules.stream() + .filter(s -> s.getBookingType() == BookingType.PRERESERVE) + .toList(); + + if (prereserveSchedules.size() != 6) { + return false; + } + + LocalDateTime now = LocalDateTime.now(); + long openCount = prereserveSchedules.stream().filter(s -> !now.isBefore(s.getBookingOpenAt())).count(); + long preOpenCount = prereserveSchedules.stream().filter(s -> now.isBefore(s.getBookingOpenAt())).count(); + + return openCount >= 1 && preOpenCount >= 1; + } catch (Exception e) { + return false; + } + } + + /** + * 추첨 실행용 공연 데이터 생성 + */ + private void lotteryForDrawExecution() { + List members1 = createMembers(3, memberRepository, passwordEncoder); + List members2 = createMembers(3, memberRepository, passwordEncoder); + List members3 = createMembers(3, memberRepository, passwordEncoder); + + Venue venue = createVenue("추첨실행-테스트공연장", venueRepository); + List
sections = createSections(venue.getVenueId(), sectionRepository, "A", "B", "C"); + List seats = createSeats(venue.getVenueId(), sections, 3, 5, seatRepository); + + Performance performance = createPerformance(venue, performanceRepository); + + LocalDateTime now = LocalDateTime.now(); + + PerformanceSchedule schedule = performanceScheduleRepository.save( + PerformanceSchedule.builder() + .performance(performance) + .roundNo(1) + .bookingType(BookingType.LOTTERY) + .bookingOpenAt(now.minusDays(3)) + .bookingCloseAt( + LocalDate.now().minusDays(1).atTime(10, 0) // ✅ 어제 + ) + .startAt(now.plusDays(10)) // 의미 없음 + .build() + ); + + createSeatGrades(performance, seats, seatGradeRepository); + + createLotteryEntry(members1, performance, schedule, SeatGradeType.STANDARD, lotteryEntryRepository); + createLotteryEntry(members2, performance, schedule, SeatGradeType.VIP, lotteryEntryRepository); + createLotteryEntry(members3, performance, schedule, SeatGradeType.ROYAL, lotteryEntryRepository); + + // drawService.executeDraws(); + + log.info("[DataInit/Lottery] 추첨 실행 대상 공연 데이터 생성 완료"); + } + + /** + * 좌석 배치용 공연 데이터 생성 (추첨 완료 상태) + */ + private void lotteryForSeatAllocation() { + List members = createMembers(5, memberRepository, passwordEncoder); + + Venue venue = createVenue("좌석배치-테스트공연장", venueRepository); + List
sections = createSections(venue.getVenueId(), sectionRepository, "A", "B", "C"); + List seats = createSeats(venue.getVenueId(), sections, 3, 5, seatRepository); + + Performance performance = createPerformance(venue, performanceRepository); + + // PerformanceSchedule schedule = performanceScheduleRepository.save( + // PerformanceSchedule.builder() + // .performance(performance) + // .roundNo(1) + // .bookingType(BookingType.LOTTERY) + // .bookingOpenAt(LocalDateTime.now().minusDays(5)) + // .bookingCloseAt(LocalDateTime.now().minusDays(3)) + // .startAt( + // LocalDate.now().plusDays(2).atTime(19, 0) // ✅ 조회 범위 내 + // ) + // .build() + // ); + + PerformanceSchedule schedule = performanceScheduleRepository.save( + PerformanceSchedule.builder() + .performance(performance) + .roundNo(1) + .bookingType(BookingType.LOTTERY) + .bookingOpenAt(LocalDateTime.now().minusDays(3)) + .bookingCloseAt( + LocalDate.now().minusDays(1).atTime(10, 0) // ✅ 어제 + ) + .startAt(LocalDateTime.now().plusDays(10)) // 의미 없음 + .build() + ); + + // 좌석 등급 생성 + createSeatGrades(performance, seats, seatGradeRepository); + + // ScheduleSeat 생성 (좌석 배치를 위해 필요) + createScheduleSeatsForSchedule( + schedule.getPerformanceScheduleId(), + seats, + scheduleSeatRepository + ); + + // 추첨 응모 생성 (STANDARD 등급만) + List entries = createLotteryEntry(members, performance, schedule, SeatGradeType.STANDARD, + lotteryEntryRepository); + + // 추첨 + // drawService.executeDraws(); + // + // List wonEntries = lotteryEntryRepository.findAllById( + // entries.stream().map(LotteryEntry::getId).toList() + // ).stream() + // .filter(entry -> entry.getStatus() == LotteryStatus.WIN) + // .toList(); + // + // log.info("[DataInit] 결제 완료: wonEntries.size()={}", wonEntries.size()); + // + // // 당첨 결제 진행 + // for (LotteryEntry entry : wonEntries) { + // try { + // PaymentPayReq req = new PaymentPayReq( + // DomainType.LOTTERY, + // PaymentMethod.CARD, + // 0L, + // entry.getUuid() + // ); + // paymentOneClickService.pay(entry.getMemberId(), req); + // log.info("[DataInit] 결제 완료: entryId={}", entry.getUuid()); + // } catch (Exception e) { + // log.warn("[DataInit] 결제 실패: entryId={}, error={}", entry.getUuid(), e.getMessage()); + // } + // } + // + // drawService.executeAllocation(); + + log.info("[DataInit/Lottery] 좌석 배치 대상 공연 데이터 생성 완료 (추첨 완료 상태)"); + } + + /** + * 특정 회차에 대한 ScheduleSeat 생성 + */ + public static List createScheduleSeatsForSchedule( + Long scheduleId, + List seats, + ScheduleSeatRepository repo + ) { + return repo.saveAll( + seats.stream() + .map(seat -> ScheduleSeat.builder() + .scheduleId(scheduleId) + .seatId(seat.getId()) + .build()) + .toList() + ); + } + + /** + * user2를 위한 티켓 생성 (양도 테스트용) + */ + private void createUser2Tickets(Member user2) { + try { + // 첫 번째 공연과 회차 조회 + List performances = performanceRepository.findAll(); + if (performances.isEmpty()) { + log.warn("[DataInit/Trade] 공연이 없어 user2 티켓 생성 불가"); + return; + } + + Performance performance = performances.get(0); + List schedules = performanceScheduleRepository + .findAllByPerformance_PerformanceIdOrderByStartAtAsc(performance.getPerformanceId()); + + if (schedules.isEmpty()) { + log.warn("[DataInit/Trade] 회차가 없어 user2 티켓 생성 불가"); + return; + } + + PerformanceSchedule schedule = schedules.get(0); + + // 아직 SOLD되지 않은 좌석 찾기 + List availableScheduleSeats = scheduleSeatRepository + .findAll() + .stream() + .filter(ss -> ss.getScheduleId().equals(schedule.getPerformanceScheduleId())) + .filter(ss -> ss.getStatus() == com.back.b2st.domain.scheduleseat.entity.SeatStatus.AVAILABLE) + .limit(3) + .toList(); + + if (availableScheduleSeats.size() < 3) { + log.warn("[DataInit/Trade] 사용 가능한 좌석이 부족해 user2 티켓 생성 불가"); + return; + } + + // user2에게 3개의 티켓 생성 + for (int i = 0; i < 3; i++) { + ScheduleSeat scheduleSeat = availableScheduleSeats.get(i); + Seat seat = seatRepository.findById(scheduleSeat.getSeatId()) + .orElseThrow(() -> new IllegalStateException("Seat not found")); + + // 좌석 상태 SOLD 처리 + scheduleSeat.sold(); + + // 예매 생성 (PENDING → COMPLETED) + Reservation reservation = Reservation.builder() + .scheduleId(schedule.getPerformanceScheduleId()) + .memberId(user2.getId()) + .expiresAt(LocalDateTime.now().plusMinutes(5)) + .build(); + + Reservation savedReservation = reservationRepository.save(reservation); + + reservationSeatRepository.save( + ReservationSeat.builder() + .reservationId(savedReservation.getId()) + .scheduleSeatId(scheduleSeat.getId()) + .build() + ); + + reservation.complete(LocalDateTime.now()); + + // 결제 생성 (DONE 상태) + Payment payment = Payment.builder() + .orderId("ORDER-TRADE-INIT-" + System.currentTimeMillis() + "-" + i) + .memberId(user2.getId()) + .domainType(DomainType.RESERVATION) + .domainId(savedReservation.getId()) + .amount(20000L) + .method(PaymentMethod.CARD) + .expiresAt(null) + .build(); + + payment.complete(LocalDateTime.now()); + paymentRepository.save(payment); + + // 티켓 생성 + Ticket ticket = Ticket.builder() + .reservationId(savedReservation.getId()) + .memberId(user2.getId()) + .seatId(seat.getId()) + .build(); + + ticketRepository.save(ticket); + + log.info("[DataInit/Trade] user2 티켓 생성 완료 - 좌석: {}구역 {}행 {}번", + seat.getSectionName(), seat.getRowLabel(), seat.getSeatNumber()); + + // orderId 중복 방지를 위한 짧은 대기 + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } catch (Exception e) { + log.error("[DataInit/Trade] user2 티켓 생성 중 오류 발생", e); + } + } + + /** + * 양도 테스트 데이터 생성 (user1 ↔ user2 티켓 소유권 이전) + */ + private void initTradeData() { + // 중복 생성 방지 + if (tradeRepository.count() > 0) { + log.info("[DataInit/Trade] 이미 양도글 존재하여 초기화 스킵"); + return; + } + + Member user1 = memberRepository.findByEmail("user1@tt.com").orElse(null); + Member user2 = memberRepository.findByEmail("user2@tt.com").orElse(null); + + if (user1 == null || user2 == null) { + log.warn("[DataInit/Trade] user1 또는 user2가 존재하지 않아 양도글 생성 스킵"); + return; + } + + // user1의 티켓 조회 (첫 번째 티켓) + List user1Tickets = ticketRepository.findByMemberId(user1.getId()); + if (user1Tickets.isEmpty()) { + log.warn("[DataInit/Trade] user1의 티켓이 없어 양도글 생성 스킵"); + return; + } + + // user2의 티켓 조회 - 없으면 생성 + List user2Tickets = ticketRepository.findByMemberId(user2.getId()); + if (user2Tickets.isEmpty()) { + log.info("[DataInit/Trade] user2 티켓이 없어 생성 시작"); + createUser2Tickets(user2); + user2Tickets = ticketRepository.findByMemberId(user2.getId()); + + if (user2Tickets.isEmpty()) { + log.warn("[DataInit/Trade] user2 티켓 생성 실패"); + return; + } + } + + // user1의 첫 번째 티켓으로 양도글 생성 + Ticket user1Ticket = user1Tickets.get(0); + Seat user1Seat = seatRepository.findById(user1Ticket.getSeatId()) + .orElseThrow(() -> new IllegalStateException("user1 Seat not found")); + Reservation user1Reservation = reservationRepository.findById(user1Ticket.getReservationId()) + .orElseThrow(() -> new IllegalStateException("user1 Reservation not found")); + PerformanceSchedule user1Schedule = performanceScheduleRepository + .findById(user1Reservation.getScheduleId()) + .orElseThrow(() -> new IllegalStateException("user1 Schedule not found")); + + // 실제 좌석 등급에서 가격 조회 + SeatGrade user1SeatGrade = seatGradeRepository + .findTopByPerformanceIdAndSeatIdOrderByIdDesc(user1Schedule.getPerformance().getPerformanceId(), + user1Seat.getId()) + .orElseThrow(() -> new IllegalStateException("user1 SeatGrade not found")); + + // 원가에서 약간 할인된 가격으로 설정 (원가의 80%) + int user1DiscountedPrice = (int)(user1SeatGrade.getPrice() * 0.8); + + com.back.b2st.domain.trade.entity.Trade user1Trade = com.back.b2st.domain.trade.entity.Trade.builder() + .memberId(user1.getId()) + .performanceId(user1Schedule.getPerformance().getPerformanceId()) + .scheduleId(user1Schedule.getPerformanceScheduleId()) + .ticketId(user1Ticket.getId()) + .type(com.back.b2st.domain.trade.entity.TradeType.TRANSFER) + .price(user1DiscountedPrice) + .totalCount(1) + .section(user1Seat.getSectionName()) + .row(user1Seat.getRowLabel()) + .seatNumber(String.valueOf(user1Seat.getSeatNumber())) + .build(); + + tradeRepository.save(user1Trade); + log.info("[DataInit/Trade] user1 양도글 생성 완료 - 티켓ID: {}, 좌석: {}구역 {}행 {}번, 원가: {}원, 양도가: {}원 ({}등급)", + user1Ticket.getId(), user1Seat.getSectionName(), user1Seat.getRowLabel(), + user1Seat.getSeatNumber(), user1SeatGrade.getPrice(), user1DiscountedPrice, user1SeatGrade.getGrade()); + + // user2의 첫 번째 티켓으로 양도글 생성 + Ticket user2Ticket = user2Tickets.get(0); + Seat user2Seat = seatRepository.findById(user2Ticket.getSeatId()) + .orElseThrow(() -> new IllegalStateException("user2 Seat not found")); + Reservation user2Reservation = reservationRepository.findById(user2Ticket.getReservationId()) + .orElseThrow(() -> new IllegalStateException("user2 Reservation not found")); + PerformanceSchedule user2Schedule = performanceScheduleRepository + .findById(user2Reservation.getScheduleId()) + .orElseThrow(() -> new IllegalStateException("user2 Schedule not found")); + + // 실제 좌석 등급에서 가격 조회 + SeatGrade user2SeatGrade = seatGradeRepository + .findTopByPerformanceIdAndSeatIdOrderByIdDesc(user2Schedule.getPerformance().getPerformanceId(), + user2Seat.getId()) + .orElseThrow(() -> new IllegalStateException("user2 SeatGrade not found")); + + // 원가에서 약간 할인된 가격으로 설정 (원가의 85%) + int user2DiscountedPrice = (int)(user2SeatGrade.getPrice() * 0.85); + + com.back.b2st.domain.trade.entity.Trade user2Trade = com.back.b2st.domain.trade.entity.Trade.builder() + .memberId(user2.getId()) + .performanceId(user2Schedule.getPerformance().getPerformanceId()) + .scheduleId(user2Schedule.getPerformanceScheduleId()) + .ticketId(user2Ticket.getId()) + .type(com.back.b2st.domain.trade.entity.TradeType.TRANSFER) + .price(user2DiscountedPrice) + .totalCount(1) + .section(user2Seat.getSectionName()) + .row(user2Seat.getRowLabel()) + .seatNumber(String.valueOf(user2Seat.getSeatNumber())) + .build(); + + tradeRepository.save(user2Trade); + log.info("[DataInit/Trade] user2 양도글 생성 완료 - 티켓ID: {}, 좌석: {}구역 {}행 {}번, 원가: {}원, 양도가: {}원 ({}등급)", + user2Ticket.getId(), user2Seat.getSectionName(), user2Seat.getRowLabel(), + user2Seat.getSeatNumber(), user2SeatGrade.getPrice(), user2DiscountedPrice, user2SeatGrade.getGrade()); + + log.info("[DataInit/Trade] 양도 테스트 데이터 생성 완료 (user1 ↔ user2)"); + } + } diff --git a/src/main/java/com/back/b2st/global/metrics/MetricsConfig.java b/src/main/java/com/back/b2st/global/metrics/MetricsConfig.java new file mode 100644 index 000000000..019ec2e6b --- /dev/null +++ b/src/main/java/com/back/b2st/global/metrics/MetricsConfig.java @@ -0,0 +1,19 @@ +package com.back.b2st.global.metrics; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; + +@Configuration +public class MetricsConfig { + + /** + * @Timed 어노테이션을 사용하기 위한 Aspect 등록 + */ + @Bean + public TimedAspect timedAspect(MeterRegistry registry) { + return new TimedAspect(registry); + } +} diff --git a/src/main/java/com/back/b2st/global/s3/dto/response/PresignedUrlRes.java b/src/main/java/com/back/b2st/global/s3/dto/response/PresignedUrlRes.java new file mode 100644 index 000000000..806c5067c --- /dev/null +++ b/src/main/java/com/back/b2st/global/s3/dto/response/PresignedUrlRes.java @@ -0,0 +1,23 @@ +package com.back.b2st.global.s3.dto.response; + +/** + * Presigned URL 응답 DTO + */ +public record PresignedUrlRes( + /** + * S3 Object Key (DB에 저장할 값) + */ + String objectKey, + + /** + * Presigned PUT URL (클라이언트가 이 URL로 업로드) + */ + String uploadUrl, + + /** + * 만료 시간 (초) + */ + int expiresInSeconds +) { +} + diff --git a/src/main/java/com/back/b2st/global/s3/service/S3Service.java b/src/main/java/com/back/b2st/global/s3/service/S3Service.java new file mode 100644 index 000000000..9f50e8c1b --- /dev/null +++ b/src/main/java/com/back/b2st/global/s3/service/S3Service.java @@ -0,0 +1,232 @@ +package com.back.b2st.global.s3.service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import com.back.b2st.global.config.S3ConfigProperties; +import com.back.b2st.global.error.code.CommonErrorCode; +import com.back.b2st.global.error.exception.BusinessException; +import com.back.b2st.global.s3.dto.response.PresignedUrlRes; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +/** + * AWS S3 서비스 + * + * - Presigned PUT URL을 생성하여 클라이언트가 직접 S3에 업로드하도록 지원합니다. + * - S3는 Private 유지: 조회는 Presigned GET URL로만 제공합니다. + * - IAM Role 기반 인증을 사용하므로 Access Key/Secret Key를 코드에 저장하지 않습니다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class S3Service { + + private final S3ConfigProperties s3Config; + private final S3Presigner s3Presigner; + + /** + * 업로드용 Presigned URL 생성 + * + * @param prefix S3 Object Key의 prefix (예: "performances/posters") + * @param contentType 파일의 Content-Type (image/jpeg, image/png, image/webp만 허용) + * @param fileSize 파일 크기 (바이트) + * @return PresignedUrlRes (objectKey, uploadUrl, expiresInSeconds) + */ + public PresignedUrlRes generatePresignedUploadUrl(String prefix, String contentType, long fileSize) { + String normalizedPrefix = validateAndNormalizePrefix(prefix); + String normalizedContentType = validateAndNormalizeContentType(contentType); + + if (fileSize <= 0 || fileSize > s3Config.maxFileSize()) { + throw new BusinessException( + CommonErrorCode.BAD_REQUEST, + String.format("파일 크기는 1바이트 이상 %d바이트 이하여야 합니다.", s3Config.maxFileSize()) + ); + } + + String objectKey = generateObjectKey(normalizedPrefix, normalizedContentType); + + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Config.bucket()) + .key(objectKey) + .contentType(normalizedContentType) + // .contentLength(fileSize) 제거: Presigned PUT에서 403 에러 유발 가능성 + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(java.time.Duration.ofSeconds(s3Config.putPresignExpirationSeconds())) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + String uploadUrl = presignedRequest.url().toString(); + + log.info("Presigned 업로드 URL 생성 완료 - ObjectKey: {}, ContentType: {}, FileSize: {} bytes", + objectKey, normalizedContentType, fileSize); + + return new PresignedUrlRes( + objectKey, + uploadUrl, + s3Config.putPresignExpirationSeconds() + ); + } catch (Exception e) { + log.error("Presigned 업로드 URL 생성 실패", e); + throw new BusinessException( + CommonErrorCode.INTERNAL_SERVER_ERROR, + "Presigned 업로드 URL 생성에 실패했습니다: " + e.getMessage() + ); + } + } + + /** + * 조회용 Presigned GET URL 생성 + * + * @param objectKey S3 Object Key + * @return 다운로드/조회 가능한 Presigned GET URL + */ + public String generatePresignedDownloadUrl(String objectKey) { + String key = validateAndNormalizeObjectKey(objectKey); + + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(s3Config.bucket()) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(java.time.Duration.ofSeconds(s3Config.getPresignExpirationSeconds())) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + + log.info("Presigned 다운로드 URL 생성 완료 - ObjectKey: {}", key); + + return presignedRequest.url().toString(); + } catch (Exception e) { + log.error("Presigned 다운로드 URL 생성 실패", e); + throw new BusinessException( + CommonErrorCode.INTERNAL_SERVER_ERROR, + "Presigned 다운로드 URL 생성에 실패했습니다: " + e.getMessage() + ); + } + } + + private String validateAndNormalizePrefix(String prefix) { + if (prefix == null) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "prefix는 필수입니다."); + } + + String s = prefix.trim(); + if (s.isEmpty()) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "prefix는 필수입니다."); + } + + int start = 0; + int end = s.length(); + + while (start < end && s.charAt(start) == '/') start++; + while (start < end && s.charAt(end - 1) == '/') end--; + + if (start >= end) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "prefix는 필수입니다."); + } + + StringBuilder sb = new StringBuilder(end - start); + boolean prevSlash = false; + + for (int i = start; i < end; i++) { + char ch = s.charAt(i); + if (ch == '/') { + if (prevSlash) continue; + prevSlash = true; + } else { + prevSlash = false; + } + sb.append(ch); + } + + if (sb.length() == 0) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "prefix는 필수입니다."); + } + + return sb.toString(); + } + + private String validateAndNormalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "Content-Type은 필수입니다."); + } + + String normalized = contentType.toLowerCase(Locale.ROOT).trim(); + + int semicolonIdx = normalized.indexOf(';'); + if (semicolonIdx > -1) { + normalized = normalized.substring(0, semicolonIdx).trim(); + } + + if ("image/jpg".equals(normalized)) { + normalized = "image/jpeg"; + } + + if (!s3Config.allowedContentTypes().contains(normalized)) { + throw new BusinessException( + CommonErrorCode.BAD_REQUEST, + String.format("허용된 이미지 형식은 %s입니다.", String.join(", ", s3Config.allowedContentTypes())) + ); + } + + return normalized; + } + + private String validateAndNormalizeObjectKey(String objectKey) { + if (objectKey == null || objectKey.trim().isEmpty()) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "objectKey는 필수입니다."); + } + + String key = objectKey.trim(); + while (key.startsWith("/")) { + key = key.substring(1); + } + + if (key.isEmpty()) { + throw new BusinessException(CommonErrorCode.BAD_REQUEST, "objectKey는 필수입니다."); + } + + return key; + } + + private String generateObjectKey(String normalizedPrefix, String normalizedContentType) { + LocalDate now = LocalDate.now(); + String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String uuid = UUID.randomUUID().toString(); + String extension = getExtensionFromContentType(normalizedContentType); + + return String.format("%s/%s/%s.%s", normalizedPrefix, datePath, uuid, extension); + } + + private String getExtensionFromContentType(String normalizedContentType) { + return switch (normalizedContentType) { + case "image/jpeg" -> "jpg"; + case "image/png" -> "png"; + case "image/webp" -> "webp"; + default -> throw new BusinessException( + CommonErrorCode.INTERNAL_SERVER_ERROR, + "지원하지 않는 Content-Type입니다: " + normalizedContentType + ); + }; + } +} diff --git a/src/main/java/com/back/b2st/global/util/SecurityUtils.java b/src/main/java/com/back/b2st/global/util/SecurityUtils.java new file mode 100644 index 000000000..f37db7f80 --- /dev/null +++ b/src/main/java/com/back/b2st/global/util/SecurityUtils.java @@ -0,0 +1,17 @@ +package com.back.b2st.global.util; + +import com.back.b2st.global.error.code.CommonErrorCode; +import com.back.b2st.global.error.exception.BusinessException; +import com.back.b2st.security.UserPrincipal; + +public final class SecurityUtils { + + private SecurityUtils() {} + + public static Long requireUserId(UserPrincipal principal) { + if (principal == null) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + } + return principal.getId(); + } +} diff --git a/src/main/java/com/back/b2st/security/SecurityConfig.java b/src/main/java/com/back/b2st/security/SecurityConfig.java index d6c5ca8d8..5dbb0fb97 100644 --- a/src/main/java/com/back/b2st/security/SecurityConfig.java +++ b/src/main/java/com/back/b2st/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +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; @@ -23,6 +24,7 @@ @Configuration @EnableWebSecurity +@EnableMethodSecurity // 메서드 수준 보안을 활성화 @RequiredArgsConstructor public class SecurityConfig { @@ -43,13 +45,33 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) .authorizeHttpRequests(auth -> auth + // 관리자 전용 경로 (URL 레벨 보호) + .requestMatchers("/api/admin/**").hasRole("ADMIN") + // 대기열 API - 인증 필요 (로그인 사용자만 접근 가능) + .requestMatchers("/api/queues/**").authenticated() + // 추첨 예매 API - 인증 필요 (로그인 사용자만 접근 가능) + .requestMatchers("/api/performances/*/lottery/entry").authenticated() + .requestMatchers("/api/performances/*/lottery/section").authenticated() + // 마이페이지 API - 인증 필요 + .requestMatchers("/api/mypage/**").authenticated() + // Actuator (health/info만 공개) + .requestMatchers( + "/actuator/health", + "/actuator/info", + "/actuator/prometheus", + "/actuator/scheduledtasks", + "/actuator/circuitbreakers", // Circuit Breaker 상태 + "/actuator/circuitbreakerevents" // Circuit Breaker 이벤트 + ).permitAll() // 인증 필요한 auth 하위 경로 (link, logout) .requestMatchers("/api/auth/link/**", "/api/auth/logout").authenticated() + // 공개 경로 .requestMatchers( "/api/members/signup", "/api/auth/**", "/h2-console/**", "/error", "/api/banks", - "/api/email/**", + "/api/email/**", "/api/performances/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" // Swagger ).permitAll() + // 나머지 모든 요청은 인증 필요 .anyRequest().authenticated()) .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) .exceptionHandling(exception -> exception diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1c4cf9eb2..d9bd09972 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -4,7 +4,7 @@ spring: on-profile: prod datasource: - url: jdbc:postgresql://tt_postgres:5432/${POSTGRES_DB} + url: jdbc:postgresql://${POSTGRES_HOST:postgres}:${POSTGRES_PORT:5432}/${POSTGRES_DB} username: ${POSTGRES_USER} password: ${POSTGRES_PASSWORD} hikari: @@ -24,6 +24,8 @@ spring: host: tt_redis port: 6379 password: ${REDIS_PASSWORD} + cluster: + nodes: ${REDIS_CLUSTER_NODES:redis-node-1:7000,redis-node-2:7001,redis-node-3:7002,redis-node-4:7003,redis-node-5:7004,redis-node-6:7005} mail: host: ${MAIL_HOST} @@ -41,6 +43,7 @@ logging: level: root: INFO org.hibernate.SQL: WARN + io.github.resilience4j: INFO # 운영 환경 Circuit Breaker 로깅 oauth: kakao: @@ -56,7 +59,7 @@ jwt: access-expiration: ${JWT_ACCESS_EXPIRATION} refresh-expiration: ${JWT_REFRESH_EXPIRATION} -# ueue 시스템 활성화 +# queue 시스템 활성화 queue: enabled: true default: @@ -67,3 +70,70 @@ queue: lua: enabled: true fallback-enabled: true + +# ==================== Circuit Breaker 설정 (운영 환경) ==================== +resilience4j: + circuitbreaker: + configs: + default: + slidingWindowType: COUNT_BASED + slidingWindowSize: 20 # 운영 환경: 더 큰 샘플 + failureRateThreshold: 50 + waitDurationInOpenState: 30s # 운영 환경: 더 긴 대기 + permittedNumberOfCallsInHalfOpenState: 5 + minimumNumberOfCalls: 10 # 운영 환경: 충분한 샘플 확보 + slowCallDurationThreshold: 2s + slowCallRateThreshold: 50 + recordExceptions: + - org.springframework.data.redis.RedisConnectionFailureException + - org.springframework.data.redis.RedisSystemException + - java.util.concurrent.TimeoutException + - java.io.IOException + ignoreExceptions: + - com.back.b2st.global.error.exception.BusinessException + + instances: + queueRedis: + baseConfig: default + failureRateThreshold: 60 + waitDurationInOpenState: 60s # 운영 환경: Redis 복구 시간 충분히 확보 + +server: + forward-headers-strategy: framework + +aws: + s3: + bucket: ${AWS_S3_BUCKET} + region: ${AWS_REGION:ap-northeast-2} + presign-expiration-seconds: ${AWS_S3_PRESIGN_EXPIRATION:300} + max-file-size: ${AWS_S3_MAX_FILE_SIZE:10485760} + allowed-content-types: + - image/jpeg + - image/png + - image/webp + +# ==================== Actuator 설정 (운영 환경) ==================== +management: + endpoints: + web: + exposure: + include: health,info,prometheus,circuitbreakers,circuitbreakerevents + + endpoint: + health: + show-details: when-authorized # 운영 환경: 인증된 사용자만 상세 정보 + + health: + circuitbreakers: + enabled: true + + # (Deprecated) management.metrics.export.prometheus.enabled -> 아래로 이동 + prometheus: + metrics: + export: + enabled: true # Prometheus 사용 시 + + metrics: + distribution: + percentiles-histogram: + resilience4j.circuitbreaker.calls: true diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index e109a4211..455f96291 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -1,11 +1,8 @@ spring: - config: - activate: - on-profile: test - ## AWS S3 자동 설정을 제외하여 테스트 시 인증 정보 에러 방지 autoconfigure: - exclude: io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration + exclude: + - io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration datasource: url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 @@ -33,8 +30,10 @@ spring: data: redis: + mode: single host: localhost port: 6379 + password: "" mail: host: localhost @@ -48,14 +47,68 @@ spring: starttls: enable: false +app: + mail: + from-address: test@example.com + from-name: Test Service + verification: + expire-minutes: 5 + code-length: 6 + jwt: secret: "testSecretKeyForUnitTestingMustBeLongEnoughToSatisfyHS256BitRequirement" access-expiration: 3600000 refresh-expiration: 86400000 +oauth: + kakao: + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: http://localhost:8080/api/auth/kakao/callback + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + issuer: https://kauth.kakao.com + jwks-uri: https://kauth.kakao.com/.well-known/jwks.json + +aws: + s3: + bucket: test-bucket + region: ap-northeast-2 + put-presign-expiration-seconds: 300 + get-presign-expiration-seconds: 300 + max-file-size: 10485760 + allowed-content-types: + - image/jpeg + - image/png + - image/webp + queue: enabled: false +alert: + enabled: false + slack: + webhook-url: "" + +# ==================== Circuit Breaker 설정 (테스트 환경 - 비활성화 또는 매우 관대) ==================== +resilience4j: + circuitbreaker: + configs: + default: + slidingWindowType: COUNT_BASED + slidingWindowSize: 100 + failureRateThreshold: 100 # 테스트: 절대 OPEN 안됨 + waitDurationInOpenState: 1s + permittedNumberOfCallsInHalfOpenState: 10 + minimumNumberOfCalls: 100 # 테스트: 절대 도달 안함 + slowCallDurationThreshold: 60s + slowCallRateThreshold: 100 + + instances: + queueRedis: + baseConfig: default + registerHealthIndicator: false # 테스트: Health Check 비활성화 + logging: level: com.back.b2st: debug @@ -63,3 +116,12 @@ logging: org.hibernate.orm.jdbc.bind: TRACE org.springframework.mail: DEBUG com.sun.mail: DEBUG + org.springframework.core.env: DEBUG + io.github.resilience4j: WARN # 테스트: Circuit Breaker 로그 최소화 + +management: + health: + mail: + enabled: false + circuitbreakers: + enabled: false # 테스트: Circuit Breaker Health Check 비활성화 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 376e8edea..0094259d9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,38 +1,45 @@ spring: profiles: - active: dev # 나중에 배포할 때는 수정해야 할 부분 + active: dev application: name: b2st datasource: url: jdbc:postgresql://localhost:5432/tt_db - username: ${POSTGRES_USER} # .env 값 참조 - password: ${POSTGRES_PASSWORD} # .env 값 참조 + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} driver-class-name: org.postgresql.Driver jpa: hibernate: - # 운영/배치 때는 validate로 변경 필요 ddl-auto: create properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true - # show_sql: true # 로깅 설정으로 대체하여 주석 처리 data: redis: + mode: ${REDIS_MODE:cluster} host: localhost port: 6379 - # .env 값 - password: ${REDIS_PASSWORD} + password: ${REDIS_PASSWORD:} + timeout: 3000ms + cluster: + nodes: ${REDIS_CLUSTER_NODES:localhost:7000,localhost:7001,localhost:7002,localhost:7003,localhost:7004,localhost:7005} + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 2 + # ==================== MAIL 설정 (기본값 추가!) ==================== mail: - host: ${MAIL_HOST} - port: ${MAIL_PORT} - username: ${MAIL_USERNAME} - password: ${MAIL_PASSWORD} + host: ${MAIL_HOST:smtp.gmail.com} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME:dummy@example.com} + password: ${MAIL_PASSWORD:dummy} properties: mail: smtp: @@ -45,36 +52,129 @@ spring: writetimeout: 5000 default-encoding: UTF-8 +alert: + enabled: true + slack: + webhook-auth: ${SLACK_WEBHOOK_AUTH:} + app: mail: - from-address: ${MAIL_FROM_ADDRESS} - from-name: ${MAIL_FROM_NAME} + from-address: ${MAIL_FROM_ADDRESS:noreply@b2st.com} + from-name: ${MAIL_FROM_NAME:B2ST TT} verification: expire-minutes: 5 code-length: 6 +oauth: + kakao: + client-id: ${KAKAO_CLIENT_ID:dummy} + client-secret: ${KAKAO_CLIENT_SECRET:dummy} + redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080/api/auth/kakao/callback} + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + issuer: https://kauth.kakao.com + jwks-uri: https://kauth.kakao.com/.well-known/jwks.json + default-nickname: 카카오사용자 + jwt: - # .env 값 - secret: ${JWT_SECRET} + secret: ${JWT_SECRET:c2VjcmV0LWtleS1mb3ItYjJzdC1wcm9qZWN0LXR0LXNlcnZpY2UtbXVzdC1iZS1sb25nLWVub3VnaA==} access-expiration: ${JWT_ACCESS_EXPIRATION:3600000} refresh-expiration: ${JWT_REFRESH_EXPIRATION:1209600000} queue: - enabled: false # false로 변경! (CI/테스트 통과용) - default: - max-active-users: 200 - entry-ttl-minutes: 10 - sync: - enabled: false # 로컬 테스트에서는 비활성화 - lua: - enabled: true - fallback-enabled: true + enabled: true + scheduler: + batch-size: 100 + fixed-delay: 3000 + leader-lock: + wait-seconds: 3 + cleanup: + expired: + fixedDelayMs: 60000 + enterable: + fixedDelayMs: 5000 + stale: + fixedDelayMs: 60000 + enabled: false + test: + enabled: false + +--- +spring: + config: + activate: + on-profile: dev + +# ==================== Circuit Breaker 설정 (추가) ==================== +resilience4j: + circuitbreaker: + configs: + default: + slidingWindowType: COUNT_BASED + slidingWindowSize: 10 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + permittedNumberOfCallsInHalfOpenState: 3 + minimumNumberOfCalls: 5 + slowCallDurationThreshold: 2s + slowCallRateThreshold: 50 + recordExceptions: + - org.springframework.data.redis.RedisConnectionFailureException + - org.springframework.data.redis.RedisSystemException + - java.util.concurrent.TimeoutException + - java.io.IOException + ignoreExceptions: + - com.back.b2st.global.error.exception.BusinessException + + instances: + queueRedis: + baseConfig: default + failureRateThreshold: 60 + waitDurationInOpenState: 30s + +# ==================== Actuator/Management 설정 (Circuit Breaker 모니터링) ==================== +management: + endpoints: + web: + exposure: + include: health, info, circuitbreakers,circuitbreakerevents, prometheus, metrics + base-path: /actuator + endpoint: + health: + show-details: when_authorized + health: + circuitbreakers: + enabled: true + prometheus: + enabled: true + metrics: + tags: + application: tt + team: b2st + distribution: + percentiles-histogram: + resilience4j.circuitbreaker.calls: true + http.server.requests: true + percentiles: + http.server.requests: 0.5, 0.95, 0.99 + slo: + http.server.requests: 100ms, 500ms, 1s + logging: level: org.hibernate.SQL: debug - # 쿼리 파라미터바인딩(?에 뭐 들어가는지) 확인용 org.hibernate.orm.jdbc.bind: TRACE - org.springframework.mail: DEBUG - com.sun.mail: DEBUG - com.back.b2st.domain.queue: INFO \ No newline at end of file + com.back.b2st.domain.queue: INFO + io.github.resilience4j: DEBUG # Circuit Breaker 로깅 + +aws: + s3: + bucket: local-dummy-bucket + region: ap-northeast-2 + presign-expiration-seconds: 300 + max-file-size: 10485760 + allowed-content-types: + - image/jpeg + - image/png + - image/webp diff --git a/src/main/resources/templates/email/lottery-cancel.html b/src/main/resources/templates/email/lottery-cancel.html new file mode 100644 index 000000000..b56f80d64 --- /dev/null +++ b/src/main/resources/templates/email/lottery-cancel.html @@ -0,0 +1,59 @@ + + + + + 당첨 취소 안내 + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+

당첨 취소 안내

+
+ +

+ 안녕하세요, 님.

+ 응모하신 공연의 예매 건이
+ 결제 기한 내 결제가 완료되지 않아
+ 당첨이 자동으로 취소되었음을 안내드립니다. +

+ +

+ 추후 진행되는 다른 공연 및 이벤트에도
+ 많은 관심과 참여 부탁드립니다. +

+ +
+ 본 메일은 발신 전용이며 회신되지 않습니다.
+ 문의사항은 고객센터를 이용해주세요.

+ © 2025 B2ST TT(Ticket&Trade). All rights reserved. +
+ + +
+ + + diff --git a/src/main/resources/templates/email/lottery-winner.html b/src/main/resources/templates/email/lottery-winner.html new file mode 100644 index 000000000..1d0f7e7d4 --- /dev/null +++ b/src/main/resources/templates/email/lottery-winner.html @@ -0,0 +1,98 @@ + + + + + 추첨 당첨 안내 + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+

추첨 당첨 안내

+
+ +

+ 안녕하세요, 고객님
+ 응모하신 공연에 당첨되셨습니다. +

+ + + + + + + + + + + + + + + + + + + + + +
+ 당첨 내역 +
좌석 등급STANDARD
당첨 매수 + 1매 +
결제 기한 + 2026-01-04 00:00 +
+ +

+ 마이페이지 > 응모 내역 조회를 통해
+ 결제 기한 내 결제를 완료해주세요. +

+ + + + + + +
+ ⚠️ 중요
+ 결제 기한이 지나면 당첨이 자동으로 취소됩니다. +
+ +
+ 본 메일은 발신 전용이며 회신되지 않습니다.
+ 문의사항은 고객센터를 이용해주세요.

+ © 2025 B2ST TT(Ticket&Trade). All rights reserved. +
+ + +
+ + + diff --git a/src/main/resources/templates/email/notification.html b/src/main/resources/templates/email/notification.html index 0dbf15f17..f3a057e36 100644 --- a/src/main/resources/templates/email/notification.html +++ b/src/main/resources/templates/email/notification.html @@ -50,6 +50,21 @@ white-space: pre-line; } + .action-button { + display: inline-block; + padding: 12px 20px; + background: #ef4444; + color: #ffffff !important; + text-decoration: none; + border-radius: 8px; + font-weight: 700; + margin-top: 12px; + } + + .action-button:hover { + background: #dc2626; + } + .footer { background-color: #f8f9fa; padding: 20px; @@ -69,6 +84,7 @@

알림

알림 메시지
+ 바로가기